From 567422f2e5ff0557e76b233fe5174812d63b515e Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Fri, 31 Jul 2026 23:50:44 +0900 Subject: [PATCH] feat: add production capability foundations --- .github/ci-gate-matrix.yml | 7 + .github/scripts/verify-gate-matrix.sh | 2 +- .github/workflows/ci-quality-gates.yml | 31 +- .../workflows/redis-production-readiness.yml | 375 ++ .gitignore | 2 + docs/registries/env-keys.yaml | 1156 +++- docs/registries/metrics.yaml | 305 + docs/registries/secrets-classification.yaml | 108 + docs/runbooks/redis-capability-incident.md | 222 + ...ver-r2-control-plane-provider-selection.md | 882 +++ ...07-28-httpclient-canonical-zero-binding.md | 120 + ...client-production-capability-foundation.md | 39 +- ...-28-messaging-first-r2-polling-producer.md | 3203 +++++++++ ...7-28-notification-production-capability.md | 4075 ++++++++++++ ...-28-objectstorage-production-capability.md | 3451 ++++++++++ .../2026-07-28-redis-cache-resilience.md | 52 + ...2026-07-28-redis-distributed-rate-limit.md | 45 + ...-redis-production-capability-foundation.md | 30 +- ...-redis-production-capability-completion.md | 662 ++ ...30-redis-lab-strict-kubeconfig-renderer.md | 241 + ...fileserver-production-capability-design.md | 122 +- ...6-production-capability-platform-design.md | 7 + ...7-26-redis-production-capability-design.md | 418 +- ...httpclient-production-capability-design.md | 42 +- ...control-plane-provider-selection-design.md | 151 +- ...-07-28-jpa-production-capability-design.md | 4824 ++++++++++++++ ...-messaging-production-capability-design.md | 5736 +++++++++++++++++ ...tification-production-capability-design.md | 4860 ++++++++++++++ ...ectstorage-production-capability-design.md | 4138 ++++++++++++ ...de-test-architecture-environment-design.md | 1154 ++++ ...026-07-28-redis-cache-resilience-design.md | 137 + ...-28-redis-distributed-rate-limit-design.md | 144 + infra/redis-lab/README.md | 134 + infra/redis-lab/bin/redis-lab | 1540 +++++ infra/redis-lab/cloud-init/node.yaml | 14 + infra/redis-lab/lib/render-kubeconfig.awk | 194 + .../kubeconfig-with-namespace.expected.yaml | 20 + ...kubeconfig-without-namespace.expected.yaml | 19 + .../kubeconfig-without-namespace.source.yaml | 19 + infra/redis-lab/test/redis-lab-contract.sh | 1885 ++++++ infra/redis-lab/versions.env | 4 + src/.env | 61 +- src/README.md | 57 +- src/adapter/inbound/web/README.md | 64 +- src/adapter/inbound/web/build.gradle | 2 + src/adapter/inbound/web/gradle.lockfile | 2 + .../inbound/web/auth/JwtDecoderConfig.java | 5 + ...itiveSessionSecurityContextRepository.java | 255 + .../web/auth/RedisSessionWebConfig.java | 34 + .../inbound/web/auth/SecurityConfig.java | 66 +- .../EdgeRateLimitTransportBridge.java | 86 + .../EdgeRateLimitTransportSettings.java | 40 + .../web/ratelimit/FixedWindowRateLimiter.java | 54 - .../web/ratelimit/RateLimitAlgorithm.java | 11 - .../web/ratelimit/RateLimitDecision.java | 15 - .../RateLimitEvaluationIdGenerator.java | 8 + .../web/ratelimit/RateLimitInterceptor.java | 100 +- .../web/ratelimit/RateLimitKeyResolver.java | 43 +- .../ratelimit/RateLimitTransportError.java | 38 + .../web/ratelimit/RateLimitWebConfig.java | 67 +- .../inbound/web/ratelimit/RateLimiter.java | 12 - .../web/ratelimit/RateLimiterFactory.java | 17 - ...eRandomRateLimitEvaluationIdGenerator.java | 38 + .../VersionedEdgeSubjectPseudonymizer.java | 45 + .../web/settings/RateLimitSettings.java | 42 - .../web/settings/SecuritySettings.java | 96 +- ...eSessionSecurityContextRepositoryTest.java | 103 + .../web/auth/RedisSessionWebConfigTest.java | 72 + .../web/auth/SecurityModeWebContractTest.java | 199 + .../EdgeRateLimitTransportBridgeTest.java | 100 + .../EdgeRateLimitTransportSettingsTest.java | 44 + .../ratelimit/FixedWindowRateLimiterTest.java | 84 - .../ratelimit/RateLimitInterceptorTest.java | 173 +- .../ratelimit/RateLimitKeyResolverTest.java | 35 +- .../web/ratelimit/RateLimitWebConfigTest.java | 35 + .../web/ratelimit/RateLimiterFactoryTest.java | 28 - ...domRateLimitEvaluationIdGeneratorTest.java | 24 + ...VersionedEdgeSubjectPseudonymizerTest.java | 32 + .../web/settings/RateLimitSettingsTest.java | 43 - .../web/settings/SecuritySettingsTest.java | 21 + src/adapter/outbound/cache-redis/CLAUDE.md | 8 + src/adapter/outbound/cache-redis/README.md | 279 +- src/adapter/outbound/cache-redis/build.gradle | 582 ++ .../outbound/cache-redis/gradle.lockfile | 257 +- .../BoundedRedisSentinelRefreshWorker.java | 231 + ...uceRedisCacheInvalidationSubscription.java | 83 + .../LettuceRedisNativeClientFactory.java | 228 + .../cache/redis/LettuceRedisRuntime.java | 194 +- .../redis/MicrometerCacheObservationPort.java | 108 + ...rometerRedisCapabilityObservationPort.java | 120 + .../NoOpRedisCapabilityObservationPort.java | 14 + .../cache/redis/RedisAtomicPrimitives.java | 194 +- .../cache/redis/RedisBinaryCommands.java | 13 +- .../cache/redis/RedisBinaryValue.java | 42 + .../cache/redis/RedisBitmapByteOffset.java | 15 + .../redis/RedisBitmapMutationResult.java | 55 + .../cache/redis/RedisBitmapOffset.java | 19 + .../cache/redis/RedisBitmapPrimitives.java | 57 + .../redis/RedisBoundedByteArrayCodec.java | 64 + .../cache/redis/RedisCacheAdapterConfig.java | 101 +- .../redis/RedisCacheConsistencyStore.java | 219 + .../cache/redis/RedisCacheEnvelopeCodec.java | 252 +- .../redis/RedisCacheInvalidationMessage.java | 164 + .../RedisCacheInvalidationSubscriber.java | 64 + .../RedisCacheInvalidationSubscription.java | 63 + .../cache/redis/RedisCacheL2Region.java | 18 + .../redis/RedisCacheRefreshCoordinator.java | 295 + .../cache/redis/RedisCacheRegionPolicy.java | 197 +- .../cache/redis/RedisCacheRegionRuntime.java | 111 + .../RedisCanonicalActivationValidator.java | 24 + .../redis/RedisCanonicalCacheConfig.java | 159 + .../redis/RedisCanonicalCacheSettings.java | 166 + .../cache/redis/RedisCanonicalConfig.java | 181 + .../redis/RedisCanonicalRoleRegistry.java | 758 +++ .../RedisCapabilityObservationEvent.java | 175 + .../redis/RedisCapabilityObservationPort.java | 7 + .../cache/redis/RedisCapabilityObserver.java | 125 + .../redis/RedisCatalogProgramInvocation.java | 321 + .../redis/RedisCatalogProgramMaterial.java | 27 + .../cache/redis/RedisCatalogProgramReply.java | 43 + .../redis/RedisCommandFailureException.java | 25 +- .../cache/redis/RedisConnectionProfile.java | 57 + .../cache/redis/RedisCounterPrimitives.java | 40 + .../cache/redis/RedisCounterResult.java | 63 + .../cache/redis/RedisDeploymentRuntime.java | 85 + .../redis/RedisDeploymentRuntimeFactory.java | 118 + .../redis/RedisDormantCommandRuntime.java | 76 + .../cache/redis/RedisDrainWaiter.java | 50 + .../redis/RedisEdgeRateLimitProvider.java | 553 ++ .../redis/RedisEfficiencyLeaseConfig.java | 56 + .../redis/RedisEfficiencyLeaseHandle.java | 394 ++ .../redis/RedisEfficiencyLeaseProvider.java | 503 ++ .../cache/redis/RedisGeoCoordinate.java | 39 + .../cache/redis/RedisGeoPrimitives.java | 81 + .../cache/redis/RedisHashPrimitives.java | 98 + .../redis/RedisHmacMaterialResolver.java | 54 + .../redis/RedisHyperLogLogPrimitives.java | 54 + .../cache/redis/RedisIdempotencyConfig.java | 77 + .../redis/RedisIdempotencyKeyFactory.java | 78 + .../redis/RedisIdempotencyLifecycle.java | 44 + .../RedisIdempotencyProgramExecutor.java | 74 + .../redis/RedisIdempotencyProgramReply.java | 10 + .../redis/RedisIdempotencyRecordCodec.java | 111 + .../cache/redis/RedisIdempotencySettings.java | 77 + .../redis/RedisIdempotencyStoreProvider.java | 568 ++ .../redis/RedisIdempotencyTokenGenerator.java | 25 + .../redis/RedisInvalidationTransport.java | 27 + .../cache/redis/RedisLeaseKeyFactory.java | 82 + .../cache/redis/RedisLeaseLifecycle.java | 44 + .../redis/RedisLeaseProgramExecutor.java | 81 + .../cache/redis/RedisLeaseProgramReply.java | 10 + .../cache/redis/RedisLeaseSettings.java | 64 + .../cache/redis/RedisLeaseTokenGenerator.java | 21 + .../RedisLeaseWaitInterruptedException.java | 7 + .../cache/redis/RedisLeaseWaitStrategy.java | 20 + .../redis/RedisLegacyStandaloneSettings.java | 87 + .../RedisLettuceClientOptionsFactory.java | 68 + .../cache/redis/RedisLettuceUriFactory.java | 213 + .../cache/redis/RedisLettuceUris.java | 119 + .../cache/redis/RedisListPrimitives.java | 56 + .../cache/redis/RedisLocalCachePolicy.java | 51 + .../cache/redis/RedisLocalCacheRegion.java | 526 ++ .../cache/redis/RedisLocalCacheSettings.java | 45 + .../cache/redis/RedisLuaProgramExecutor.java | 49 +- .../redis/RedisLuaVersionedSessionStore.java | 621 ++ .../cache/redis/RedisNativeClientFactory.java | 17 + .../cache/redis/RedisNativeClientHandle.java | 11 + .../redis/RedisOwnedPhysicalKeyMaterial.java | 12 + .../cache/redis/RedisPhysicalKey.java | 73 + .../cache/redis/RedisPrimitiveCatalog.java | 204 + .../cache/redis/RedisPrimitiveCommands.java | 7 + .../cache/redis/RedisPrimitiveCursor.java | 85 + .../cache/redis/RedisPrimitiveDescriptor.java | 123 + .../redis/RedisPrimitiveElementResult.java | 30 + .../cache/redis/RedisPrimitiveExecutor.java | 46 + .../cache/redis/RedisPrimitiveHashEntry.java | 11 + .../cache/redis/RedisPrimitiveId.java | 51 + .../cache/redis/RedisPrimitiveInvocation.java | 771 +++ .../cache/redis/RedisPrimitiveKey.java | 74 + .../cache/redis/RedisPrimitiveKeyFactory.java | 37 + .../cache/redis/RedisPrimitiveLimit.java | 15 + .../redis/RedisPrimitiveMutationResult.java | 73 + .../cache/redis/RedisPrimitivePage.java | 64 + .../RedisPrimitiveProgramDispatcher.java | 313 + .../cache/redis/RedisPrimitiveReply.java | 183 + .../redis/RedisPrimitiveScanOutcome.java | 22 + .../redis/RedisPrimitiveSemanticClass.java | 19 + .../cache/redis/RedisPrimitiveStructure.java | 13 + .../cache/redis/RedisPrimitiveValue.java | 52 + .../cache/redis/RedisProgramCatalog.java | 1561 ++++- .../cache/redis/RedisProgramContract.java | 151 + .../cache/redis/RedisProgramDescriptor.java | 88 + .../cache/redis/RedisProgramExecutor.java | 4 +- .../outbound/cache/redis/RedisProgramId.java | 57 +- .../cache/redis/RedisRateLimitConfig.java | 62 + .../cache/redis/RedisRateLimitRuntime.java | 35 + .../cache/redis/RedisRateLimitSettings.java | 176 + .../cache/redis/RedisRateProgramDecision.java | 8 + .../cache/redis/RedisRateProgramExecutor.java | 8 + .../cache/redis/RedisRateProgramReply.java | 36 + .../cache/redis/RedisRateProgramStatus.java | 11 + .../cache/redis/RedisRoleCommandRouter.java | 1117 ++++ .../redis/RedisRoutableCommandRuntime.java | 44 + .../cache/redis/RedisRouteIdentity.java | 73 + .../cache/redis/RedisRuntimeConnector.java | 10 + .../cache/redis/RedisRuntimeSettings.java | 76 +- .../cache/redis/RedisScriptRecovery.java | 78 + .../redis/RedisSemanticAclProbeCatalog.java | 64 + .../redis/RedisSemanticAclScriptContract.java | 93 + .../cache/redis/RedisSemanticAclSurface.java | 136 + .../RedisSemanticProbeObservationCache.java | 153 + .../cache/redis/RedisSemanticProbePlan.java | 91 + .../redis/RedisSemanticReadinessProbe.java | 459 ++ .../redis/RedisSentinelDiscoveredRoute.java | 35 + .../redis/RedisSentinelDiscoveryClient.java | 325 + .../RedisSentinelFailoverCoordinator.java | 334 + .../redis/RedisSentinelMasterDiscovery.java | 362 ++ .../redis/RedisSentinelRefreshWorker.java | 19 + .../redis/RedisSentinelRuntimeConnector.java | 172 + .../cache/redis/RedisSessionConfig.java | 116 + .../redis/RedisSessionEnvelopeCodec.java | 296 + .../cache/redis/RedisSessionSettings.java | 99 + .../cache/redis/RedisSetPrimitives.java | 74 + .../cache/redis/RedisSortedSetPrimitives.java | 106 + .../cache/redis/RedisSortedSetScore.java | 31 + .../cache/redis/RedisStringCacheRegion.java | 539 +- .../redis/RedisStringValuePrimitives.java | 123 + .../cache/redis/RedisStructuredCommands.java | 9 + .../redis/RedisStructuredProgramExecutor.java | 134 + .../RedisTemporaryConnectionException.java | 9 + .../redis/RedisTopologyCommandRuntime.java | 1130 ++++ .../outbound/cache/redis/RedisTtlMillis.java | 30 + .../cache/redis/RedisVersionedSession.java | 155 + .../RedisVersionedSessionRepository.java | 302 + .../SafeRedisCapabilityObservationPort.java | 22 + .../redis/VersionedRedisSessionStore.java | 345 + .../redis/config/RedisDeploymentSettings.java | 97 + .../RedisDeploymentSettingsFactory.java | 342 + .../redis/config/RedisEvictionPolicy.java | 8 + .../redis/config/RedisProviderSettings.java | 212 + .../cache/redis/config/RedisRole.java | 8 + .../cache/redis/config/RedisRoleBinding.java | 4 + .../cache/redis/key/RedisKeyBuilder.java | 32 +- .../readiness/RedisTestImageRegistry.java | 52 + .../RedisTestImageRegistryLoader.java | 111 + .../runtime/RedisClientRuntimeSettings.java | 110 + .../DestroyableRedisCredentialsProvider.java | 112 + .../redis/security/DestroyableRedisPem.java | 67 + .../security/DestroyableRedisSecret.java | 72 + .../RedisCredentialMaterialProvider.java | 13 + .../RedisCredentialRotationCoordinator.java | 248 + .../redis/security/RedisRotatableRuntime.java | 14 + .../redis/security/RedisSecretReference.java | 57 + .../security/RedisSslOptionsFactory.java | 95 + .../security/RedisTrustMaterialProvider.java | 8 + .../VersionedRedisCredentialMaterial.java | 63 + .../security/VersionedRedisTrustMaterial.java | 62 + .../redis/idempotency-program-set.json | 201 + .../resources/redis/lease-program-set.json | 122 + .../redis/primitive-program-set.json | 362 ++ .../src/main/resources/redis/program-set.json | 201 +- .../resources/redis/rate-program-set.json | 172 + .../scripts/bounded-geo-admission-v1.lua | 69 + .../redis/scripts/bounded-get-v1.lua | 9 + .../bounded-hash-field-admission-v1.lua | 56 + .../scripts/bounded-hash-scan-page-v1.lua | 49 + .../scripts/bounded-list-admission-v1.lua | 48 + .../redis/scripts/bounded-mget-v1.lua | 53 + .../scripts/bounded-set-admission-v1.lua | 53 + .../scripts/bounded-set-scan-page-v1.lua | 49 + .../scripts/bounded-zset-admission-v1.lua | 69 + .../redis/scripts/cache-refresh-claim-v1.lua | 66 + .../scripts/compare-and-set-with-ttl-v1.lua | 35 + .../redis/scripts/guarded-list-trim-v1.lua | 48 + .../redis/scripts/hash-revision-cas-v1.lua | 70 + .../redis/scripts/idempotency-claim-v1.lua | 290 + .../redis/scripts/idempotency-complete-v1.lua | 185 + .../redis/scripts/idempotency-fail-v1.lua | 143 + .../redis/scripts/idempotency-inspect-v1.lua | 197 + .../redis/scripts/idempotency-release-v1.lua | 129 + .../redis/scripts/idempotency-renew-v1.lua | 121 + .../redis/scripts/idempotency-start-v1.lua | 120 + .../scripts/increment-with-initial-ttl-v1.lua | 186 + .../redis/scripts/lease-acquire-v1.lua | 112 + .../redis/scripts/lease-inspect-v1.lua | 72 + .../redis/scripts/lease-release-v1.lua | 72 + .../redis/scripts/lease-renew-v1.lua | 81 + .../redis/scripts/rate-fixed-window-v1.lua | 146 + .../redis/scripts/rate-fixed-window-v2.lua | 334 + .../redis/scripts/rate-sliding-counter-v1.lua | 203 + .../redis/scripts/rate-sliding-counter-v2.lua | 376 ++ .../redis/scripts/rate-token-bucket-v1.lua | 195 + .../redis/scripts/rate-token-bucket-v2.lua | 370 ++ .../scripts/region-generation-bump-v1.lua | 72 + .../scripts/region-generation-init-v1.lua | 69 + .../replace-if-observed-with-ttl-v1.lua | 30 + .../scripts/semantic-capability-acl-v1.lua | 77 + .../redis/scripts/session-create-v1.lua | 19 + .../redis/scripts/session-inspect-v1.lua | 16 + .../redis/scripts/session-rotate-v1.lua | 25 + .../redis/scripts/session-save-if-live-v1.lua | 24 + .../session-tombstone-and-delete-v1.lua | 15 + .../scripts/session-touch-if-live-v1.lua | 17 + .../redis/scripts/zset-bounded-trim-v1.lua | 56 + .../redis/semantic-readiness-contract.json | 87 + .../resources/redis/session-program-set.json | 175 + .../RedisCacheCompatibilityEvidenceTest.java | 110 + .../redis/RedisCacheFaultEvidenceTest.java | 177 + .../redis/RedisCacheSecurityEvidenceTest.java | 127 + .../RedisCacheStandaloneEvidenceTest.java | 119 + .../RedisEfficiencyLeaseEvidenceTest.java | 306 + .../redis/RedisEvidenceImageRegistry.java | 44 + .../redis/RedisIdempotencyEvidenceTest.java | 341 + .../redis/RedisPhysicalKeyTestFactory.java | 25 + .../RedisPrimitiveCatalogEvidenceTest.java | 751 +++ ...edisProgramScriptRecoveryEvidenceTest.java | 98 + .../redis/RedisProgramTestInvocations.java | 23 + .../redis/RedisRateLimitEvidenceTest.java | 308 + ...SemanticReadinessSecurityEvidenceTest.java | 290 + .../cache/redis/RedisSessionEvidenceTest.java | 383 ++ .../redis/RedisSoftLeaseEvidenceTest.java | 259 + .../RedisStandaloneEvidenceContainer.java | 42 + .../redis/RedisTlsAclEvidenceContainer.java | 379 ++ .../RedisToxiproxyEvidenceContainer.java | 110 + ...BoundedRedisSentinelRefreshWorkerTest.java | 162 + .../LettuceRedisNativeClientFactoryTest.java | 122 + .../redis/LettuceRedisRuntimeServiceTest.java | 1101 +++- .../cache/redis/LettuceRedisRuntimeTest.java | 2 +- .../MicrometerCacheObservationPortTest.java | 88 + ...terRedisCapabilityObservationPortTest.java | 189 + .../RecordingRedisCapabilityObservations.java | 26 + .../redis/RedisAtomicPrimitivesTest.java | 97 +- .../redis/RedisBoundedByteArrayCodecTest.java | 20 + .../redis/RedisCacheConsistencyStoreTest.java | 257 + .../RedisCacheInvalidationMessageTest.java | 61 + .../RedisCacheRefreshCoordinatorTest.java | 248 + .../redis/RedisCanonicalCacheConfigTest.java | 196 + .../RedisCanonicalCacheSettingsTest.java | 84 + .../cache/redis/RedisCanonicalConfigTest.java | 343 + .../RedisCanonicalRoleHealthSnapshotTest.java | 436 ++ .../redis/RedisCanonicalRoleRegistryTest.java | 789 +++ ...edisCapabilityObservationContractTest.java | 320 + .../redis/RedisConnectionProfileTest.java | 30 + .../RedisDeploymentRuntimeFactoryTest.java | 304 + .../redis/RedisEdgeRateLimitProviderTest.java | 539 ++ .../redis/RedisEfficiencyLeaseConfigTest.java | 181 + .../RedisEfficiencyLeaseProviderTest.java | 228 + ...edisEfficiencyLeaseRuntimeServiceTest.java | 206 + .../redis/RedisIdempotencyConfigTest.java | 179 + .../RedisIdempotencyProgramCatalogTest.java | 89 + .../RedisIdempotencyRecordCodecTest.java | 80 + .../RedisIdempotencyRuntimeServiceTest.java | 370 ++ .../redis/RedisIdempotencySettingsTest.java | 85 + .../RedisIdempotencyStoreProviderTest.java | 304 + .../redis/RedisLeaseProgramCatalogTest.java | 57 + .../cache/redis/RedisLeaseSettingsTest.java | 75 + .../RedisLettuceConfigurationFactoryTest.java | 347 + .../cache/redis/RedisLettuceUrisTest.java | 85 + .../cache/redis/RedisLifecycleTest.java | 194 + .../redis/RedisLocalCachePolicyTest.java | 48 + .../redis/RedisLocalCacheRegionTest.java | 386 ++ .../redis/RedisLocalCacheSettingsTest.java | 39 + .../redis/RedisLuaProgramExecutorTest.java | 80 +- .../RedisLuaVersionedSessionStoreTest.java | 226 + .../RedisMetricRegistryContractTest.java | 163 + .../redis/RedisOptionalCacheRecoveryTest.java | 381 ++ .../redis/RedisPhysicalKeyTestFactory.java | 25 + .../cache/redis/RedisPrimitiveBitmapTest.java | 19 + .../RedisPrimitiveBoundaryVectorTest.java | 175 + .../RedisPrimitiveCommandRuntimeTest.java | 171 + .../RedisPrimitiveCompletenessMatrixTest.java | 112 + .../redis/RedisPrimitiveCounterTest.java | 23 + .../redis/RedisPrimitiveDescriptorTest.java | 117 + .../cache/redis/RedisPrimitiveGeoTest.java | 24 + .../cache/redis/RedisPrimitiveHashTest.java | 62 + .../redis/RedisPrimitiveHyperLogLogTest.java | 23 + .../cache/redis/RedisPrimitiveListTest.java | 26 + .../RedisPrimitiveProgramCatalogTest.java | 177 + .../RedisPrimitiveProgramDispatcherTest.java | 211 + .../cache/redis/RedisPrimitiveRouterTest.java | 185 + .../RedisPrimitiveRuntimeServiceTest.java | 108 + .../cache/redis/RedisPrimitiveSetTest.java | 23 + .../redis/RedisPrimitiveSortedSetTest.java | 77 + .../redis/RedisPrimitiveStringValueTest.java | 26 + .../redis/RedisPrimitiveSurfaceTest.java | 214 + .../redis/RedisPrimitiveTestCommands.java | 44 + .../cache/redis/RedisProgramCatalogTest.java | 29 +- .../RedisProgramManifestContractTest.java | 244 + .../redis/RedisProgramTestInvocations.java | 58 + .../cache/redis/RedisRateLimitConfigTest.java | 212 + .../redis/RedisRateLimitSettingsTest.java | 179 + .../redis/RedisRateProgramCatalogTest.java | 186 + .../redis/RedisRoleCommandRouterTest.java | 1228 ++++ .../cache/redis/RedisRuntimeSettingsTest.java | 66 + .../RedisSemanticAclScriptContractTest.java | 37 + ...edisSemanticOutcomeClassificationTest.java | 221 + ...edisSemanticProbeManifestContractTest.java | 123 + ...edisSemanticProbeObservationCacheTest.java | 375 ++ .../RedisSemanticReadinessProbeTest.java | 563 ++ .../RedisSentinelDiscoveryClientTest.java | 689 ++ .../RedisSentinelFailoverCoordinatorTest.java | 747 +++ .../RedisSentinelMasterDiscoveryTest.java | 267 + .../RedisSentinelRuntimeConnectorTest.java | 411 ++ .../cache/redis/RedisSessionConfigTest.java | 127 + .../redis/RedisSessionEnvelopeCodecTest.java | 103 + .../cache/redis/RedisSessionSettingsTest.java | 78 + .../cache/redis/RedisSpringLifecycleTest.java | 404 ++ .../redis/RedisStringCacheRegionTest.java | 762 ++- .../RedisStructuredProgramExecutorTest.java | 239 + .../RedisTopologyCommandRuntimeCloseTest.java | 219 + ...gyCommandRuntimeSentinelBootstrapTest.java | 272 + ...edisTopologyCommandRuntimeSurfaceTest.java | 29 + ...pologyConnectionFailureClassifierTest.java | 130 + .../RedisVersionedSessionRepositoryTest.java | 273 + .../RedisDeploymentSettingsFactoryTest.java | 598 ++ .../RedisProviderSettingsBindingTest.java | 222 + .../RedisTestImageRegistryLoaderTest.java | 113 + .../RedisCredentialMaterialProviderTest.java | 72 + ...edisCredentialRotationCoordinatorTest.java | 248 + .../RedisTrustMaterialProviderTest.java | 125 + .../src/test/resources/redis-test-ca.pem | 19 + src/adapter/outbound/fileserver/CLAUDE.md | 35 +- src/adapter/outbound/fileserver/README.md | 136 +- src/adapter/outbound/fileserver/build.gradle | 12 +- .../fileserver/CompiledFileDestination.java | 198 + .../fileserver/DurablePublicationRecord.java | 255 + .../outbound/fileserver/FileExportConfig.java | 11 +- ...roperties.java => FileExportSettings.java} | 2 +- .../FilePublicationCanonicalDigests.java | 201 + .../fileserver/FilePublicationProvider.java | 15 + .../FilePublishRequestFingerprint.java | 22 +- .../FileserverActivationValidator.java | 22 + .../fileserver/FileserverBindingCompiler.java | 227 + .../FileserverControlRecordCodec.java | 855 +++ .../fileserver/FileserverR2Config.java | 92 + .../fileserver/FileserverR2Settings.java | 41 + .../fileserver/FileserverR2Validation.java | 189 + .../LocalPersistentControlPlane.java | 1467 +++++ .../LocalPersistentPayloadOperations.java | 1098 ++++ .../LocalPersistentPublicationProvider.java | 896 +++ .../LocalPersistentRecoveryVerifier.java | 210 + .../LocalPersistentRootAttestor.java | 770 +++ .../LocalPersistentRootEvidence.java | 41 + .../LocalPublicationJournalCodec.java | 21 + .../fileserver/PrivateFileManifest.java | 77 + .../fileserver/PublishedReferenceRecord.java | 48 + .../fileserver/R2PublishedReferenceCodec.java | 79 + .../RoutingFilePublicationAdapter.java | 42 + .../fileserver/FilePublicationConfigTest.java | 4 +- .../FileserverBindingCompilerTest.java | 969 +++ .../FileserverControlRecordCodecTest.java | 924 +++ .../FileserverCrashScenarioMain.java | 473 ++ .../fileserver/FileserverR2ConfigTest.java | 530 ++ .../LocalPersistentControlPlaneTest.java | 2083 ++++++ .../LocalPersistentCrashRecoveryTest.java | 229 + .../LocalPersistentPayloadOperationsTest.java | 757 +++ ...ocalPersistentPublicationProviderTest.java | 511 ++ ...ocalPersistentPublicationRecoveryTest.java | 1121 ++++ .../LocalPersistentRootAttestorTest.java | 769 +++ .../LocalPublicationJournalTest.java | 34 + src/adapter/outbound/httpclient/CLAUDE.md | 6 + src/adapter/outbound/httpclient/README.md | 33 +- src/adapter/outbound/httpclient/build.gradle | 2 - .../outbound/httpclient/gradle.lockfile | 1 - .../httpclient/OutboundHttpClientConfig.java | 13 +- .../httpclient/OutboundHttpSettings.java | 48 +- .../HttpClientActivationResolver.java | 87 + .../HttpClientCanonicalConfiguration.java | 83 + ...ttpClientCanonicalConfigurationBinder.java | 162 + .../activation/HttpClientExpectedState.java | 7 + .../HttpClientReadinessCardRegistry.java | 37 + .../HttpOperationCatalogRegistry.java | 56 + .../ResolvedHttpClientCapability.java | 23 + .../operation/HttpOperationCatalog.java | 7 + .../OutboundHttpResilienceConfig.java | 8 +- .../httpclient/OutboundHttpSettingsTest.java | 133 +- .../HttpClientActivationResolverTest.java | 181 + ...lientCanonicalConfigurationBinderTest.java | 135 + .../OutboundHttpResilienceConfigTest.java | 7 +- src/adapter/outbound/notification/CLAUDE.md | 22 +- src/adapter/outbound/notification/README.md | 54 +- .../outbound/persistence-jpa/CLAUDE.md | 11 +- .../outbound/persistence-jpa/README.md | 13 + .../idempotency/IdempotencyReaper.java | 5 + .../idempotency/IdempotencyStoreAdapter.java | 5 + .../transaction/SpringTransactionPort.java | 10 + .../SpringTransactionPortTest.java | 104 + src/app-bootstrap/README.md | 13 +- src/app-bootstrap/build.gradle | 21 + src/app-bootstrap/gradle.lockfile | 546 +- .../HttpClientCompositionConfig.java | 56 + .../idempotency/IdempotencyConfig.java | 5 + .../IdempotencyProviderSelectionConfig.java | 69 + .../IdempotencyProviderSettings.java | 20 + ...EnvironmentCredentialMaterialProvider.java | 42 + .../redis/RedisEnvironmentMaterialConfig.java | 22 + ...EnvironmentMaterialProviderDescriptor.java | 11 + .../RedisEnvironmentMaterialResolver.java | 73 + ...RedisEnvironmentTrustMaterialProvider.java | 42 + .../runtime/SecretSourceValidator.java | 57 + .../redis/RedisHealthContributorConfig.java | 151 + .../AuthenticationModeCompositionConfig.java | 48 + .../security/AuthenticationModeSettings.java | 19 + .../src/main/resources/application.yml | 245 +- ...inationRuntimeCompositionContractTest.java | 167 + ...OptionalCacheColdStartCompositionTest.java | 138 + ...RedisCanonicalCompositionContractTest.java | 232 + .../redis/RedisCiAggregatorContractTest.java | 140 + .../RedisDefaultActivationContractTest.java | 139 + ...mpotencyProviderSelectionContractTest.java | 203 + .../OptionalAdapterBeanGatingTest.java | 35 + .../ArchitectureViolationFixtureTest.java | 50 +- .../architecture/CleanArchitectureTest.java | 297 +- .../DisabledAdapterArchitectureTest.java | 83 +- .../RootWriteTransactionBoundaryUseCase.java | 33 + .../activation/EvilActivationLeak.java | 15 + .../outbound/settingsbypass/EvilSettings.java | 11 + .../MissingTransactionBoundaryUseCase.java | 5 +- ...apterConditionalExecutionContractTest.java | 14 - .../EnabledIfHttpCircuitBreakerEnabled.java | 23 - .../EnabledIfHttpRetryEnabled.java | 22 - .../HttpClientCompositionConfigTest.java | 181 + .../RedisEnvironmentMaterialProviderTest.java | 116 + .../RuntimeHealthLifecycleContractTest.java | 54 +- .../runtime/SecretSourceValidatorTest.java | 99 + .../RedisHealthContributorConfigTest.java | 191 + ...thenticationModeCompositionConfigTest.java | 54 + .../src/test/resources/application-test.yml | 12 - src/application-core/CLAUDE.md | 31 +- src/application-core/README.md | 48 +- src/application-core/build.gradle | 26 + src/application-core/gradle.lockfile | 64 +- .../application/cache/CacheAsideExecutor.java | 457 ++ .../application/cache/CacheAsidePolicy.java | 54 + .../cache/CacheCancellationToken.java | 36 + .../application/cache/CacheLookup.java | 88 +- .../cache/CacheObservationEvent.java | 97 + .../cache/CacheObservationPort.java | 8 + .../cache/CacheObservationToken.java | 25 + .../application/cache/CacheRecordIntent.java | 2 + .../cache/CacheRecordMetadata.java | 31 +- .../cache/CacheRefreshClaimAttempt.java | 35 + .../cache/CacheRefreshClaimOutcome.java | 42 + .../cache/CacheRefreshCoordinationPolicy.java | 47 + .../cache/CacheRefreshCoordinationPort.java | 24 + .../cache/CacheRefreshOperationToken.java | 62 + .../cache/CacheRefreshOwnerToken.java | 59 + .../cache/CacheRefreshReleaseOutcome.java | 23 + .../application/cache/CacheRegionPort.java | 7 + .../application/cache/CacheResult.java | 131 + .../application/cache/CacheSingleFlight.java | 210 + .../cache/CacheSourceBulkhead.java | 69 + .../application/cache/CacheSourceLoader.java | 8 + .../cache/CacheWriteCondition.java | 37 + .../cache/DisabledCacheObservationPort.java | 18 + .../DisabledCacheRefreshCoordinationPort.java | 39 + .../application/cache/SourceFailure.java | 22 + .../application/cache/SourceLoadOutcome.java | 56 + .../filepublication/FilePublishReceipt.java | 1 + .../idempotency/IdempotencyClaimAttempt.java | 15 + .../idempotency/IdempotencyClaimOutcome.java | 85 + .../idempotency/IdempotencyClaimRequest.java | 60 + .../IdempotencyCompleteOutcome.java | 55 + .../idempotency/IdempotencyExecutorV2.java | 230 + .../idempotency/IdempotencyFailOutcome.java | 51 + .../IdempotencyFailureDisposition.java | 7 + .../idempotency/IdempotencyInspection.java | 69 + .../IdempotencyInspectionRequest.java | 19 + .../idempotency/IdempotencyOwner.java | 18 + .../IdempotencyRecoveryRequiredException.java | 11 + .../IdempotencyReleaseOutcome.java | 54 + .../idempotency/IdempotencyRenewOutcome.java | 50 + .../idempotency/IdempotencyStartOutcome.java | 50 + .../idempotency/IdempotencyStorePortV2.java | 35 + .../IdempotencyUnavailableException.java | 11 + .../idempotency/IdempotencyV2Validation.java | 50 + .../idempotency/IdempotentAction.java | 34 + .../idempotency/RequestFingerprint.java | 4 +- .../lease/DistributedLeasePort.java | 16 + .../lease/LeaseAcquireOutcome.java | 54 + .../application/lease/LeaseAttempt.java | 15 + .../application/lease/LeaseGuarantee.java | 6 + .../application/lease/LeaseHandle.java | 58 + .../lease/LeaseInspectionOutcome.java | 39 + .../lease/LeaseInspectionRequest.java | 20 + .../lease/LeaseReleaseOutcome.java | 32 + .../application/lease/LeaseRenewOutcome.java | 41 + .../application/lease/LeaseRequest.java | 34 + .../application/lease/LeaseState.java | 9 + .../lease/LeaseUnavailableCategory.java | 8 + .../application/lease/LeaseValidation.java | 67 + .../application/lease/LeaseWatchdog.java | 221 + .../ApplyNotificationReceiptCommand.java | 13 + .../ApplyNotificationReceiptResult.java | 21 + .../ApplyNotificationReceiptUseCase.java | 80 + .../notification/ConsentCheckMode.java | 7 + .../notification/EmailRecipientReference.java | 19 + ...ializeNotificationWriterFencesCommand.java | 32 + ...lizeNotificationWriterFencesOperation.java | 13 + ...tializeNotificationWriterFencesResult.java | 21 + ...ializeNotificationWriterFencesUseCase.java | 55 + .../InlineNotificationAttemptPort.java | 11 + .../NormalizedNotificationReceiptCommand.java | 16 + .../NotificationAdmissionClass.java | 8 + .../NotificationAdmissionGateCommand.java | 53 + .../NotificationAdmissionGateUseCase.java | 89 + .../NotificationAdmissionReadinessPort.java | 154 + .../NotificationAppendResult.java | 31 + .../NotificationApplicationException.java | 20 + .../notification/NotificationAttemptId.java | 9 + ...NotificationCanonicalWriterFenceGuard.java | 44 + .../NotificationCanonicalWriterFencePort.java | 38 + .../NotificationCanonicalWriterRouteSet.java | 79 + ...ationCapabilityCompatibilityValidator.java | 92 + .../notification/NotificationChannel.java | 7 + .../notification/NotificationDeliveryId.java | 9 + .../NotificationDeliveryStorePort.java | 204 + .../NotificationDispatchCommand.java | 13 + .../NotificationDispatchResult.java | 37 + .../NotificationDispatchUseCase.java | 191 + .../NotificationEvidenceTrustSnapshot.java | 24 + .../notification/NotificationFaultScope.java | 9 + .../notification/NotificationFrozenPlan.java | 89 + .../NotificationIntentAppendPort.java | 11 + .../notification/NotificationIntentDraft.java | 84 + .../notification/NotificationIntentId.java | 24 + .../notification/NotificationKindId.java | 9 + .../notification/NotificationKindPolicy.java | 124 + ...NotificationLegacyWriterPermitCommand.java | 103 + .../NotificationLegacyWriterPermitResult.java | 45 + ...NotificationLegacyWriterPermitUseCase.java | 59 + .../NotificationMaintenanceCommand.java | 20 + .../NotificationMaintenanceResult.java | 16 + .../NotificationMaintenanceStorePort.java | 23 + .../NotificationMaintenanceUseCase.java | 44 + .../notification/NotificationMode.java | 7 + .../NotificationOperationsSnapshot.java | 57 + .../NotificationOperationsSnapshotPort.java | 8 + .../NotificationOperationsSnapshotQuery.java | 13 + ...NotificationOperationsSnapshotUseCase.java | 37 + .../notification/NotificationPlanPort.java | 8 + .../NotificationPlanningResult.java | 32 + .../NotificationProviderAttemptPort.java | 8 + ...ificationProviderCapabilityDescriptor.java | 41 + .../notification/NotificationReasonCode.java | 12 + .../NotificationReceiptEventId.java | 9 + .../notification/NotificationReceiptFact.java | 34 + ...ionReceiptIngressCapabilityDescriptor.java | 25 + .../NotificationReceiptProjection.java | 70 + .../NotificationReceiptStorePort.java | 52 + .../NotificationRecipientReference.java | 8 + .../NotificationReconciliationPort.java | 19 + .../NotificationRequestResult.java | 71 + .../notification/NotificationRouteId.java | 9 + .../NotificationRouteStrategy.java | 8 + .../NotificationSignedEvidenceHeader.java | 281 + ...NotificationStoreCapabilityDescriptor.java | 41 + .../NotificationTechnicalSuppressionPort.java | 32 + .../NotificationTemplateParameters.java | 36 + .../notification/NotificationTemplateRef.java | 12 + .../NotificationTemplateValue.java | 103 + .../NotificationWriterCutoverPort.java | 17 + .../NotificationWriterInventoryEvidence.java | 36 + ...onWriterInventoryEvidenceVerifierPort.java | 14 + .../NotificationWriterOwnership.java | 7 + ...cationWriterQuiescenceAttestationPort.java | 34 + .../NotificationWriterRouteSet.java | 157 + .../notification/ProviderAttemptOutcome.java | 92 + ...econcileNotificationDeliveriesCommand.java | 19 + ...ReconcileNotificationDeliveriesResult.java | 18 + ...econcileNotificationDeliveriesUseCase.java | 84 + ...ionWriterQuiescenceAttestationCommand.java | 23 + ...nWriterQuiescenceAttestationOperation.java | 14 + ...tionWriterQuiescenceAttestationResult.java | 29 + ...ionWriterQuiescenceAttestationUseCase.java | 80 + .../notification/RetryDisposition.java | 9 + ...edNotificationWriterInventoryManifest.java | 46 + ...dNotificationWriterQuiescenceManifest.java | 74 + .../notification/SlackAudienceReference.java | 25 + .../notification/SubmissionCertainty.java | 8 + ...tchNotificationWriterOwnershipCommand.java | 130 + ...hNotificationWriterOwnershipOperation.java | 18 + ...itchNotificationWriterOwnershipResult.java | 70 + ...tchNotificationWriterOwnershipUseCase.java | 100 + .../notification/TargetAttemptOutcome.java | 25 + ...piredNotificationWriterPermitsCommand.java | 31 + ...redNotificationWriterPermitsOperation.java | 13 + ...xpiredNotificationWriterPermitsResult.java | 21 + ...piredNotificationWriterPermitsUseCase.java | 49 + ...estedRootTransactionRejectedException.java | 12 + .../transaction/TransactionPort.java | 21 + .../RedisPolicyBoundaryContractTest.java | 79 + .../cache/CacheAsideExecutorTest.java | 888 +++ .../cache/CacheObservationContractTest.java | 71 + .../CacheRefreshCoordinationContractTest.java | 75 + .../cache/CacheRegionContractTest.java | 70 +- .../cache/CacheResilienceConcurrencyTest.java | 200 + .../FilePublicationContractTest.java | 37 + .../IdempotencyExecutorV2Test.java | 286 + .../IdempotencyV2ContractTest.java | 162 + .../lease/DistributedLeaseV2ContractTest.java | 144 + .../application/lease/LeaseWatchdogTest.java | 132 + ...zeNotificationWriterFencesUseCaseTest.java | 149 + .../NotificationAdmissionGateUseCaseTest.java | 167 + ...ficationCanonicalWriterFenceGuardTest.java | 65 + ...nCapabilityCompatibilityValidatorTest.java | 134 + .../NotificationDispatchUseCaseTest.java | 395 ++ .../NotificationKindPolicyTest.java | 110 + ...ficationLegacyWriterPermitUseCaseTest.java | 149 + .../NotificationMaintenanceUseCaseTest.java | 69 + ...ficationOperationsSnapshotUseCaseTest.java | 111 + .../NotificationPlanningBoundaryTest.java | 155 + .../NotificationPortBoundaryTest.java | 82 + .../NotificationReceiptReducerTest.java | 231 + .../NotificationRequestResultTest.java | 144 + .../NotificationValueContractTest.java | 222 + ...cileNotificationDeliveriesUseCaseTest.java | 165 + ...riterQuiescenceAttestationUseCaseTest.java | 216 + ...otificationWriterOwnershipUseCaseTest.java | 173 + ...dNotificationWriterPermitsUseCaseTest.java | 89 + ...PublishPendingOutboxEventsUseCaseTest.java | 5 + .../transaction/TransactionPortTest.java | 27 + src/build.gradle | 1404 +++- src/config/architecture/modules.json | 1 + src/config/redis/program-set.schema.json | 169 + src/config/redis/readiness-cards.yaml | 48 + src/gradle/redis-test-images.properties | 5 + src/sample-portfolio/gradle.lockfile | 1 + .../src/main/resources/application.yml | 62 +- .../worklog/CreateWorkLogOutboxTest.java | 10 + ...ListRecentWorkLogSummariesUseCaseTest.java | 5 + .../worklog/WorkLogUseCasesTest.java | 5 + .../WorkLogAuthorizationContractTest.java | 5 + .../authz/WorkLogAuthorizationE2ETest.java | 5 + .../src/test/resources/application-test.yml | 18 - src/shared-contract/CLAUDE.md | 2 + src/shared-contract/README.md | 19 + src/shared-contract/build.gradle | 26 + src/shared-contract/gradle.lockfile | 64 +- ...eRateLimitProviderNeutralContractTest.java | 75 + .../health/RedisHealthSnapshotProvider.java | 103 + .../shared/ratelimit/EdgeRateLimitPort.java | 12 + .../ratelimit/EdgeRateLimitSubject.java | 43 + .../ratelimit/EdgeSubjectPseudonymizer.java | 13 + .../shared/ratelimit/RateLimitAlgorithm.java | 7 + .../shared/ratelimit/RateLimitBounds.java | 84 + .../shared/ratelimit/RateLimitDecision.java | 59 + .../RateLimitEvaluationDedupPolicy.java | 54 + .../ratelimit/RateLimitFailurePolicy.java | 6 + .../shared/ratelimit/RateLimitOutcome.java | 59 + .../shared/ratelimit/RateLimitPolicy.java | 155 + .../shared/ratelimit/RateLimitRequest.java | 43 + .../ratelimit/RateLimitSubjectDigest.java | 19 + .../shared/ratelimit/RateParameters.java | 73 + .../ratelimit/RateLimitContractTest.java | 189 + .../RateLimitEvaluationDedupPolicyTest.java | 39 + .../shared/ratelimit/RateLimitPolicyTest.java | 159 + 757 files changed, 132385 insertions(+), 2146 deletions(-) create mode 100644 .github/workflows/redis-production-readiness.yml create mode 100644 .gitignore create mode 100644 docs/runbooks/redis-capability-incident.md create mode 100644 docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md create mode 100644 docs/superpowers/plans/2026-07-28-httpclient-canonical-zero-binding.md create mode 100644 docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md create mode 100644 docs/superpowers/plans/2026-07-28-notification-production-capability.md create mode 100644 docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md create mode 100644 docs/superpowers/plans/2026-07-28-redis-cache-resilience.md create mode 100644 docs/superpowers/plans/2026-07-28-redis-distributed-rate-limit.md create mode 100644 docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md create mode 100644 docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md create mode 100644 docs/superpowers/specs/2026-07-28-jpa-production-capability-design.md create mode 100644 docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md create mode 100644 docs/superpowers/specs/2026-07-28-notification-production-capability-design.md create mode 100644 docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md create mode 100644 docs/superpowers/specs/2026-07-28-production-grade-test-architecture-environment-design.md create mode 100644 docs/superpowers/specs/2026-07-28-redis-cache-resilience-design.md create mode 100644 docs/superpowers/specs/2026-07-28-redis-distributed-rate-limit-design.md create mode 100644 infra/redis-lab/README.md create mode 100755 infra/redis-lab/bin/redis-lab create mode 100644 infra/redis-lab/cloud-init/node.yaml create mode 100644 infra/redis-lab/lib/render-kubeconfig.awk create mode 100644 infra/redis-lab/test/fixtures/kubeconfig-with-namespace.expected.yaml create mode 100644 infra/redis-lab/test/fixtures/kubeconfig-without-namespace.expected.yaml create mode 100644 infra/redis-lab/test/fixtures/kubeconfig-without-namespace.source.yaml create mode 100755 infra/redis-lab/test/redis-lab-contract.sh create mode 100644 infra/redis-lab/versions.env create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettings.java delete mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java delete mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java delete mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitEvaluationIdGenerator.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitTransportError.java delete mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java delete mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGenerator.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java delete mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfigTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityModeWebContractTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridgeTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettingsTest.java delete mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfigTest.java delete mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGeneratorTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizerTest.java delete mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorker.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisCacheInvalidationSubscription.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPort.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPort.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/NoOpRedisCapabilityObservationPort.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryValue.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapByteOffset.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapMutationResult.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapOffset.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodec.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStore.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessage.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscriber.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscription.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheL2Region.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinator.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalActivationValidator.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfig.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfig.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistry.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationEvent.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationPort.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObserver.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramInvocation.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramMaterial.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramReply.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfile.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterResult.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDormantCommandRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDrainWaiter.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProvider.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfig.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseHandle.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProvider.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoCoordinate.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHashPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHmacMaterialResolver.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHyperLogLogPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfig.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyKeyFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyLifecycle.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramExecutor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramReply.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodec.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProvider.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyTokenGenerator.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisInvalidationTransport.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseKeyFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseLifecycle.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramExecutor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramReply.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseTokenGenerator.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitInterruptedException.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitStrategy.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLegacyStandaloneSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceClientOptionsFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUriFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUris.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisListPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicy.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegion.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStore.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientHandle.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOwnedPhysicalKeyMaterial.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKey.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalog.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommands.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCursor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveElementResult.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveExecutor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashEntry.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveId.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocation.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKey.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKeyFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveLimit.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveMutationResult.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitivePage.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcher.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveReply.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveScanOutcome.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSemanticClass.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStructure.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveValue.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramContract.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfig.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramDecision.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramExecutor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramReply.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramStatus.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouter.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoutableCommandRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRouteIdentity.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeConnector.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisScriptRecovery.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclProbeCatalog.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContract.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclSurface.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCache.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbePlan.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbe.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveredRoute.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClient.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinator.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRefreshWorker.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnector.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfig.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodec.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSetPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetPrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetScore.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringValuePrimitives.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredCommands.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutor.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTemporaryConnectionException.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTtlMillis.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSession.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepository.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/SafeRedisCapabilityObservationPort.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStore.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisEvictionPolicy.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistry.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoader.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/runtime/RedisClientRuntimeSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisCredentialsProvider.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisPem.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisSecret.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProvider.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinator.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisRotatableRuntime.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSecretReference.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSslOptionsFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProvider.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisCredentialMaterial.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisTrustMaterial.java create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/idempotency-program-set.json create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/lease-program-set.json create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/primitive-program-set.json create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-geo-admission-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-get-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-field-admission-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-scan-page-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-list-admission-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-mget-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-admission-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-scan-page-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-zset-admission-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/cache-refresh-claim-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-set-with-ttl-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/guarded-list-trim-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/hash-revision-cas-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-claim-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-complete-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-fail-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-inspect-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-release-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-renew-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-start-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/increment-with-initial-ttl-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-acquire-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-inspect-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-release-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-renew-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v2.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v2.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v2.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-bump-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-init-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/replace-if-observed-with-ttl-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/semantic-capability-acl-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-create-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-inspect-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-rotate-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-save-if-live-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-tombstone-and-delete-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-touch-if-live-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/zset-bounded-trim-v1.lua create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/semantic-readiness-contract.json create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis/session-program-set.json create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheCompatibilityEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheFaultEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStandaloneEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEvidenceImageRegistry.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramScriptRecoveryEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStandaloneEvidenceContainer.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTlsAclEvidenceContainer.java create mode 100644 src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisToxiproxyEvidenceContainer.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorkerTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactoryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPortTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPortTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RecordingRedisCapabilityObservations.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodecTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStoreTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessageTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinatorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfigTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfigTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleHealthSnapshotTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationContractTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfileTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactoryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProviderTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfigTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProviderTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseRuntimeServiceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfigTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramCatalogTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodecTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRuntimeServiceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProviderTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramCatalogTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceConfigurationFactoryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUrisTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLifecycleTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicyTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegionTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStoreTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisMetricRegistryContractTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheRecoveryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBitmapTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBoundaryVectorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommandRuntimeTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCompletenessMatrixTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCounterTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveGeoTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHyperLogLogTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveListTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramCatalogTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcherTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRouterTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRuntimeServiceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSetTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSortedSetTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStringValueTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSurfaceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveTestCommands.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramManifestContractTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfigTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramCatalogTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouterTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContractTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticOutcomeClassificationTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeManifestContractTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCacheTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbeTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClientTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinatorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnectorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfigTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodecTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettingsTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSpringLifecycleTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeCloseTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSentinelBootstrapTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSurfaceTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyConnectionFailureClassifierTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepositoryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettingsBindingTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoaderTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProviderTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinatorTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProviderTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/resources/redis-test-ca.pem create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java rename src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/{FileExportProperties.java => FileExportSettings.java} (98%) create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java create mode 100644 src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java create mode 100644 src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSettings.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentCredentialMaterialProvider.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderDescriptor.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialResolver.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentTrustMaterialProvider.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeSettings.java create mode 100644 src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCoordinationRuntimeCompositionContractTest.java create mode 100644 src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheColdStartCompositionTest.java create mode 100644 src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCanonicalCompositionContractTest.java create mode 100644 src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCiAggregatorContractTest.java create mode 100644 src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisDefaultActivationContractTest.java create mode 100644 src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisIdempotencyProviderSelectionContractTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java delete mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java delete mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfigTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfigTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsidePolicy.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheCancellationToken.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationEvent.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationToken.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimAttempt.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPolicy.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOperationToken.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOwnerToken.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshReleaseOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceBulkhead.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceLoader.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheObservationPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheRefreshCoordinationPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/SourceFailure.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/cache/SourceLoadOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimAttempt.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyCompleteOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailureDisposition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspection.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspectionRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyOwner.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecoveryRequiredException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyReleaseOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRenewOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStartOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePortV2.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyUnavailableException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyV2Validation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentAction.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAcquireOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAttempt.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseHandle.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseReleaseOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRenewOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseState.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseUnavailableCategory.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseValidation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java create mode 100644 src/application-core/src/redisPolicyContractTest/java/dev/caskeleton/application/redis/RedisPolicyBoundaryContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/cache/CacheObservationContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRefreshCoordinationContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/cache/CacheResilienceConcurrencyTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2Test.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyV2ContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/lease/DistributedLeaseV2ContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/lease/LeaseWatchdogTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java create mode 100644 src/config/redis/program-set.schema.json create mode 100644 src/config/redis/readiness-cards.yaml create mode 100644 src/gradle/redis-test-images.properties create mode 100644 src/shared-contract/src/edgeRateLimitContractTest/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitProviderNeutralContractTest.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitPort.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeSubjectPseudonymizer.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitAlgorithm.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitBounds.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitFailurePolicy.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitSubjectDigest.java create mode 100644 src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java create mode 100644 src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitContractTest.java create mode 100644 src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicyTest.java create mode 100644 src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitPolicyTest.java diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index e68b3a6..65cf48e 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -101,6 +101,13 @@ gates: workflow: ci-quality-gates.yml job: gate-matrix-lint execution: job + - id: redis-standalone + release_blocking: true + mechanism: workflow-job + ref: redis-standalone + workflow: ci-quality-gates.yml + job: redis-standalone + execution: job - id: quality-release-gate release_blocking: true mechanism: workflow-job diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index 8a5fc44..a2374c5 100644 --- a/.github/scripts/verify-gate-matrix.sh +++ b/.github/scripts/verify-gate-matrix.sh @@ -5,7 +5,7 @@ readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" -readonly EXPECTED_GATE_COUNT=19 +readonly EXPECTED_GATE_COUNT=20 if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 diff --git a/.github/workflows/ci-quality-gates.yml b/.github/workflows/ci-quality-gates.yml index 172db40..d492375 100644 --- a/.github/workflows/ci-quality-gates.yml +++ b/.github/workflows/ci-quality-gates.yml @@ -70,6 +70,33 @@ jobs: - name: Verify the gate matrix against the repository run: bash .github/scripts/verify-gate-matrix.sh + redis-standalone: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Verify standalone Redis policy, provider, and composition contracts + working-directory: src + run: >- + ./gradlew + :application-core:redisPolicyContractTest + :shared-contract:edgeRateLimitContractTest + :adapter:outbound:cache-redis:check + :app-bootstrap:redisCompositionTest + verifyCleanArchitectureDependencies + verifyEnvKeys + verifyPublicPathSnapshot + verifyConfigurationPropertiesProcessor + --no-daemon --stacktrace + # Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check. quarantine: runs-on: ubuntu-latest @@ -94,6 +121,7 @@ jobs: - quality-gates - sample-off - gate-matrix-lint + - redis-standalone if: always() runs-on: ubuntu-latest steps: @@ -102,9 +130,10 @@ jobs: QUALITY_RESULT: ${{ needs.quality-gates.result }} SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }} MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }} + REDIS_RESULT: ${{ needs.redis-standalone.result }} run: | set -euo pipefail - for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}"; do + for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}" "${REDIS_RESULT}"; do if [[ "${result}" != "success" ]]; then echo "::error::release-gate: required job result was ${result}" exit 1 diff --git a/.github/workflows/redis-production-readiness.yml b/.github/workflows/redis-production-readiness.yml new file mode 100644 index 0000000..d04b827 --- /dev/null +++ b/.github/workflows/redis-production-readiness.yml @@ -0,0 +1,375 @@ +name: redis-production-readiness + +on: + schedule: + - cron: "23 18 * * *" + workflow_dispatch: + push: + tags: + - "v*-rc.*" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + resolve-redis-readiness: + runs-on: ubuntu-latest + outputs: + selected: ${{ steps.resolve.outputs.selected }} + candidates: ${{ steps.resolve.outputs.candidates }} + selected_count: ${{ steps.resolve.outputs.selected_count }} + sentinel_required: ${{ steps.resolve.outputs.sentinel_required }} + cluster_required: ${{ steps.resolve.outputs.cluster_required }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - name: Generate the strict checked-in readiness control artifact + working-directory: src + run: ./gradlew writeRedisCiMatrix --no-daemon --stacktrace + - id: resolve + name: Transport the generated matrix to job outputs + shell: python + run: | + import json + import os + from pathlib import Path + + matrix_path = Path( + "src/build/redis-evidence/control/redis-readiness-matrix.json" + ) + matrix = json.loads(matrix_path.read_text(encoding="utf-8")) + if matrix["releaseQualification"] != "NOT_CLAIMED": + raise SystemExit("resolver control artifact must not claim release qualification") + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: + stream.write( + "selected=" + + json.dumps(matrix["selected"], separators=(",", ":")) + + "\n" + ) + stream.write( + "candidates=" + + json.dumps( + matrix["implementedCandidates"], separators=(",", ":") + ) + + "\n" + ) + stream.write(f"selected_count={matrix['selectedCount']}\n") + stream.write( + "sentinel_required=" + + str(matrix["topologyJobs"]["sentinel"]).lower() + + "\n" + ) + stream.write( + "cluster_required=" + + str(matrix["topologyJobs"]["cluster"]).lower() + + "\n" + ) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-readiness-control + path: src/build/redis-evidence/control + if-no-files-found: error + retention-days: 30 + + redis-security: + needs: resolve-redis-readiness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:redisSecurityTest --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-security-evidence + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 14 + + redis-sentinel: + needs: resolve-redis-readiness + if: ${{ needs.resolve-redis-readiness.outputs.sentinel_required == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:redisSentinelTest --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-sentinel-evidence + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 14 + + redis-cluster: + needs: resolve-redis-readiness + if: ${{ needs.resolve-redis-readiness.outputs.cluster_required == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:redisClusterTest --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-cluster-evidence + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 14 + + redis-fault: + needs: resolve-redis-readiness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:redisFaultTest --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-fault-evidence + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 14 + + redis-compatibility: + needs: resolve-redis-readiness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:redisCompatibilityTest --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-compatibility-evidence + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 14 + + selected-card-readiness: + needs: resolve-redis-readiness + if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.resolve-redis-readiness.outputs.selected) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew ${{ matrix.readinessTask }} --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-selected-${{ matrix.cardId }} + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 30 + + redis-all-candidates: + needs: resolve-redis-readiness + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - id: redis-tests + working-directory: src + run: ./gradlew redisAllImplementedCandidates --no-daemon --stacktrace + - id: redis-evidence-sanitizer + if: always() + working-directory: src + run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace + - if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: redis-all-candidates-evidence + path: src/adapter/outbound/cache-redis/build/redis-evidence + if-no-files-found: error + retention-days: 14 + + redis-production-readiness: + needs: + - resolve-redis-readiness + - selected-card-readiness + if: ${{ always() && needs.resolve-redis-readiness.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Require the exact selected matrix result + shell: python + env: + SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }} + SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }} + run: | + import os + + selected_count_text = os.environ["SELECTED_COUNT"] + selected_job_result = os.environ["SELECTED_JOB_RESULT"] + if not selected_count_text.isdecimal(): + raise SystemExit("selected_count must be a non-negative integer") + selected_count = int(selected_count_text) + expected_result = "skipped" if selected_count == 0 else "success" + if selected_job_result != expected_result: + raise SystemExit( + "selected-card-readiness result mismatch: " + f"count={selected_count}, expected={expected_result}, " + f"actual={selected_job_result}" + ) + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0 + with: + name: redis-readiness-control + path: ${{ runner.temp }}/redis-readiness/control + - if: >- + ${{ + needs.resolve-redis-readiness.outputs.selected_count != '0' + && needs.selected-card-readiness.result == 'success' + }} + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0 + with: + pattern: redis-selected-* + path: ${{ runner.temp }}/redis-readiness/selected + - name: Record the downloaded selected artifact inventory + shell: python + env: + GITHUB_RUN_ID: ${{ github.run_id }} + SELECTED_JSON: ${{ needs.resolve-redis-readiness.outputs.selected }} + SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }} + SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }} + REDIS_SELECTED_DIRECTORY: ${{ runner.temp }}/redis-readiness/selected + REDIS_CI_RESULT_FILE: ${{ runner.temp }}/redis-readiness/redis-ci-result.json + run: | + import json + import os + from pathlib import Path + + selected = json.loads(os.environ["SELECTED_JSON"]) + if not isinstance(selected, list): + raise SystemExit("selected matrix must be a JSON array") + expected_names = sorted( + "redis-selected-" + entry["cardId"] for entry in selected + ) + if len(expected_names) != int(os.environ["SELECTED_COUNT"]): + raise SystemExit("selected_count does not match the selected matrix") + if len(expected_names) != len(set(expected_names)): + raise SystemExit("selected matrix contains duplicate artifact names") + + selected_directory = Path(os.environ["REDIS_SELECTED_DIRECTORY"]) + actual_names = ( + sorted(path.name for path in selected_directory.iterdir() if path.is_dir()) + if selected_directory.is_dir() + else [] + ) + if actual_names != expected_names: + raise SystemExit( + "downloaded selected artifact inventory mismatch: " + f"expected={expected_names}, actual={actual_names}" + ) + + result = { + "schemaVersion": 1, + "runId": os.environ["GITHUB_RUN_ID"], + "selectedCount": len(expected_names), + "selectedJobResult": os.environ["SELECTED_JOB_RESULT"], + "selectedArtifactNames": actual_names, + } + result_path = Path(os.environ["REDIS_CI_RESULT_FILE"]) + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text( + json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + - if: ${{ needs.resolve-redis-readiness.outputs.selected_count == '0' }} + working-directory: src + run: >- + ./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness + -PredisControlDirectory=${{ runner.temp }}/redis-readiness/control + -PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json + --no-daemon --stacktrace + - if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }} + working-directory: src + run: >- + ./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness + -PredisControlDirectory=${{ runner.temp }}/redis-readiness/control + -PredisEvidenceDirectory=${{ runner.temp }}/redis-readiness/selected + -PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json + --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e9c600 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +src/**/bin/ diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 6384940..25131cb 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -2,7 +2,7 @@ # SSOT: wiki/projects/ca-tmpl/registries/env-keys.yaml # Schema owner: feature-contract-registry-governance # Owner branch: feature-env-driven-runtime-configuration -# Last updated: 2026-06-06 +# Last updated: 2026-07-28 # # Conventions: # - Application-owned env uses `APP_` prefix, fully unified (D2, 2026-06-05): @@ -498,214 +498,6 @@ env_keys: compatibility_impact: behavior-change required_test: env-contract:pool-max-lifetime-valid - # === Outbound HTTP timeout / retry / circuit breaker (env-driven branch + outbound-http-client-baseline) === - - - name: APP_OUTBOUND_HTTP_CONNECT_TIMEOUT - # source: feature-outbound-http-client-baseline 2026-05-22 - # "outbound HTTP timeout default = connect 2s / read 5s / global call 10s" - type: duration - default: 2s - allowed_values: null - classification: public-config - required: true - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: spring_duration_shorthand_non_zero - compatibility_impact: behavior-change - required_test: outbound-contract:connect-timeout-bounded - - - name: APP_OUTBOUND_HTTP_READ_TIMEOUT - # source: feature-outbound-http-client-baseline 2026-05-22 "read 5s" - type: duration - default: 5s - allowed_values: null - classification: public-config - required: true - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: spring_duration_shorthand_non_zero - compatibility_impact: behavior-change - required_test: outbound-contract:read-timeout-bounded - - - name: APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT - # source: feature-outbound-http-client-baseline 2026-05-22 "global call 10s" - type: duration - default: 10s - allowed_values: null - classification: public-config - required: true - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: spring_duration_shorthand_non_zero - compatibility_impact: behavior-change - required_test: outbound-contract:global-call-timeout-bounded - - - name: APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS - type: int - default: 128 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: httpclient-production-capability - validation: int_range_1_10000 - compatibility_impact: additive - required_test: outbound-contract:maximum-in-flight-calls-bounded - - - name: APP_OUTBOUND_HTTP_RETRY_ENABLED - # source: feature-outbound-http-client-baseline 2026-05-22 - # "retry 기본값은 disabled이며, 활성화 시 retryable registry error와 low-cardinality retry metric이 필수" - type: boolean - default: false - allowed_values: [true, false] - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: boolean_strict - compatibility_impact: behavior-change - required_test: outbound-contract:retry-metric-when-enabled - - - name: APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS - # source: feature-outbound-http-resilience-config 2026-06-12 - # "retry maxAttempts 외부화 — 기본값 3 (기존 하드코딩 보존)" - type: int - default: 3 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: positive_int - compatibility_impact: behavior-change - required_test: outbound-contract:retry-max-attempts-configurable - - - name: APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF - # source: feature-outbound-http-resilience-config 2026-06-12 - # "exponential backoff 시작 간격 100ms (기존 하드코딩 보존)" - type: duration - default: 100ms - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: spring_duration_shorthand_non_zero - compatibility_impact: behavior-change - required_test: outbound-contract:retry-initial-backoff-configurable - - - name: APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER - # source: feature-outbound-http-resilience-config 2026-06-12 - # "exponential backoff multiplier 2.0 (기존 하드코딩 보존)" - type: double - default: 2.0 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: double_ge_1 - compatibility_impact: behavior-change - required_test: outbound-contract:retry-backoff-multiplier-configurable - - - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED - # source: feature-outbound-http-client-baseline 2026-05-22 - # "circuit breaker | Resilience4j optional env | disabled local" - type: boolean - default: false - allowed_values: [true, false] - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: boolean_strict - compatibility_impact: behavior-change - required_test: outbound-contract:cb-metric-when-enabled - - - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD - # source: feature-outbound-http-resilience-config 2026-06-12 - # "failure rate threshold 50% (Resilience4j ofDefaults 보존)" - type: float - default: 50 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: float_in_0_exclusive_to_100 - compatibility_impact: behavior-change - required_test: outbound-contract:cb-failure-rate-configurable - - - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE - # source: feature-outbound-http-resilience-config 2026-06-12 - # "sliding window size 100 (COUNT_BASED, Resilience4j ofDefaults 보존)" - type: int - default: 100 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: positive_int - compatibility_impact: behavior-change - required_test: outbound-contract:cb-sliding-window-configurable - - - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS - # source: feature-outbound-http-resilience-config 2026-06-12 - # "minimum number of calls 100 (Resilience4j ofDefaults 보존)" - type: int - default: 100 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: positive_int - compatibility_impact: behavior-change - required_test: outbound-contract:cb-minimum-calls-configurable - - - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE - # source: feature-outbound-http-resilience-config 2026-06-12 - # "wait duration in open state 60s (Resilience4j ofDefaults 보존)" - type: duration - default: 60s - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: spring_duration_shorthand_non_zero - compatibility_impact: behavior-change - required_test: outbound-contract:cb-wait-duration-configurable - - - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN - # source: feature-outbound-http-resilience-config 2026-06-12 - # "permitted calls in half-open 10 (Resilience4j ofDefaults 보존)" - type: int - default: 10 - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: positive_int - compatibility_impact: behavior-change - required_test: outbound-contract:cb-permitted-half-open-configurable - - - name: APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT - # source: feature-outbound-http-client-baseline 2026-05-22 - # "response size limit default = 10MB streaming threshold" - type: data_size - default: 10MB - allowed_values: null - classification: public-config - required: false - reload_policy: restart-only - owner_branch: feature-outbound-http-client-baseline - validation: spring_data_size - compatibility_impact: behavior-change - required_test: outbound-contract:response-size-streaming - # === Tracing / Observability (feature-distributed-tracing-contract) === - name: OTEL_EXPORTER_OTLP_ENDPOINT @@ -1096,6 +888,198 @@ env_keys: compatibility_impact: behavior-change required_test: security-contract:cors-preflight-max-age + - name: APP_SECURITY_AUTH_MODE + type: enum + default: jwt + allowed_values: [jwt, redis-session] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: enum_in_allowed_values + compatibility_impact: additive + required_test: redis-session-contract:auth-mode-exclusive + + - name: APP_SESSION_COOKIE_NAME + type: string + default: CA_SESSION + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: bounded_cookie_name + compatibility_impact: additive + required_test: redis-session-contract:cookie-hardened + + - name: APP_SESSION_COOKIE_SECURE + type: boolean + default: true + allowed_values: [true] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: must_be_true + compatibility_impact: additive + required_test: redis-session-contract:cookie-hardened + + - name: APP_SESSION_COOKIE_HTTP_ONLY + type: boolean + default: true + allowed_values: [true] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: must_be_true + compatibility_impact: additive + required_test: redis-session-contract:cookie-hardened + + - name: APP_SESSION_COOKIE_SAME_SITE + type: enum + default: Lax + allowed_values: [Lax, Strict, None] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: enum_in_allowed_values + compatibility_impact: additive + required_test: redis-session-contract:cookie-hardened + + - name: APP_SESSION_COOKIE_PATH + type: string + default: / + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: bounded_absolute_path + compatibility_impact: additive + required_test: redis-session-contract:cookie-hardened + + - name: APP_SESSION_CSRF_COOKIE_NAME + type: string + default: XSRF-TOKEN + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: bounded_cookie_name + compatibility_impact: additive + required_test: redis-session-contract:csrf-enabled + + - name: APP_SESSION_CSRF_HEADER_NAME + type: string + default: X-XSRF-TOKEN + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: bounded_header_name + compatibility_impact: additive + required_test: redis-session-contract:csrf-enabled + + - name: APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT + type: string + default: local + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: lowercase_slug + compatibility_impact: additive + required_test: redis-session-contract:key-namespace + + - name: APP_SESSION_IDLE_TIMEOUT + type: duration + default: 30m + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: positive_duration_le_30d + compatibility_impact: additive + required_test: redis-session-contract:idle-expiry + + - name: APP_SESSION_ABSOLUTE_LIFETIME + type: duration + default: 8h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: positive_duration_le_30d + compatibility_impact: additive + required_test: redis-session-contract:absolute-expiry + + - name: APP_SESSION_TOUCH_INTERVAL + type: duration + default: 1m + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: positive_duration_lt_idle + compatibility_impact: additive + required_test: redis-session-contract:bounded-touch + + - name: APP_SESSION_TOMBSTONE_TTL + type: duration + default: 5m + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: positive_duration_gt_route_drain + compatibility_impact: additive + required_test: redis-session-contract:logout-tombstone + + - name: APP_SESSION_MAXIMUM_ENVELOPE_BYTES + type: int + default: 32768 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: int_range_64_1048576 + compatibility_impact: additive + required_test: redis-session-contract:serializer-bounded + + - name: APP_SESSION_MAXIMUM_ATTRIBUTES + type: int + default: 64 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: int_range_1_256 + compatibility_impact: additive + required_test: redis-session-contract:serializer-bounded + + - name: APP_SESSION_MAXIMUM_SCALAR_BYTES + type: int + default: 8192 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: positive_int_le_envelope + compatibility_impact: additive + required_test: redis-session-contract:serializer-bounded + - name: APP_SECURITY_JWT_ISSUER # source: feature-security-operational-baseline 2026-05-22 # "issuer mismatch | 401 | AUTH_ISSUER_MISMATCH" @@ -1186,17 +1170,17 @@ env_keys: # === Rate limit / Idempotency (feature-rate-limit-idempotency-contract) === - name: APP_RATE_LIMIT_ENABLED - # source: feature-rate-limit-idempotency-contract — "rate limit과 idempotency를 API/runtime 운영 표면에 포함" + # Inbound enforcement stays disabled until an exact provider is selected. type: boolean - default: true + default: false allowed_values: [true, false] classification: public-config required: false reload_policy: restart-only - owner_branch: feature-rate-limit-idempotency-contract + owner_branch: redis-production-capability-completion validation: boolean_strict - compatibility_impact: behavior-change - required_test: rate-limit-contract:enabled-by-default + compatibility_impact: additive + required_test: redis-rate-limit-contract:transport-provider-default-pair - name: APP_RATE_LIMIT_CLIENT_IP_MODE # source: feature-rate-limit-idempotency-contract — rate-limit client IP source policy. @@ -1212,6 +1196,378 @@ env_keys: compatibility_impact: behavior-change required_test: rate-limit-contract:client-ip-mode + - name: APP_RATE_LIMIT_REDIS_ENABLED + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: boolean_strict + compatibility_impact: additive + required_test: rate-limit-contract:redis-disabled-zero-side-effect + + - name: APP_RATE_LIMIT_PROVIDER + type: enum + default: disabled + allowed_values: [disabled, redis] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: enum_strict + compatibility_impact: additive + required_test: rate-limit-contract:provider-explicit + + - name: APP_RATE_LIMIT_ROLE + type: enum + default: coordination + allowed_values: [coordination] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: enum_strict + compatibility_impact: additive + required_test: rate-limit-contract:coordination-role-only + + - name: APP_RATE_LIMIT_FAILURE_POLICY + type: enum + default: fail-closed + allowed_values: [fail-closed] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: enum_strict + compatibility_impact: additive + required_test: rate-limit-contract:fail-closed-only + + - name: APP_RATE_LIMIT_DEFAULT_POLICY_ID + type: string + default: api-default + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: lowercase_slug + compatibility_impact: additive + required_test: rate-limit-contract:default-policy-resolves + + - name: APP_RATE_LIMIT_FAILURE_RETRY_AFTER + type: duration + default: 100ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: spring_duration_shorthand_non_zero_le_30d + compatibility_impact: additive + required_test: rate-limit-contract:failure-retry-bounded + + - name: APP_RATE_LIMIT_HASH_KEY_VERSION + type: int + default: 1 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: int_1_to_9999 + compatibility_impact: additive + required_test: rate-limit-contract:hash-version-bounded + + - name: APP_RATE_LIMIT_KEY_VERSION + type: int + default: 1 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: int_1_to_9999 + compatibility_impact: additive + required_test: rate-limit-contract:key-version-bounded + + - name: APP_RATE_LIMIT_REDIS_HOST + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: non_empty_string_when_rate_limit_redis_enabled + compatibility_impact: additive + required_test: rate-limit-contract:redis-host-required + + - name: APP_RATE_LIMIT_REDIS_PORT + type: int + default: 6379 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: port_range_1_65535 + compatibility_impact: additive + required_test: rate-limit-contract:redis-port-bounded + + - name: APP_RATE_LIMIT_REDIS_PASSWORD + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: must_not_be_local_dev_sentinel_in_prod + compatibility_impact: additive + required_test: rate-limit-contract:redis-password-no-leak + + - name: APP_RATE_LIMIT_REDIS_TRUST_PEM + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: non_blank_pem_when_coordination_role_is_bound + compatibility_impact: additive + required_test: redis-contract:coordination-trust-material-no-leak + + - name: APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: base64_min_32_bytes_when_rate_limit_redis_enabled + compatibility_impact: additive + required_test: rate-limit-contract:redis-hmac-required + + - name: APP_SESSION_REDIS_PASSWORD + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: non_blank_when_session_role_is_bound + compatibility_impact: additive + required_test: redis-contract:session-password-no-leak + + - name: APP_SESSION_REDIS_TRUST_PEM + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: non_blank_pem_when_session_role_is_bound + compatibility_impact: additive + required_test: redis-contract:session-trust-material-no-leak + + - name: APP_SESSION_REDIS_KEY_HMAC_SECRET + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: base64_min_32_bytes_when_redis_session_enabled + compatibility_impact: additive + required_test: redis-session-contract:key-hmac-no-leak + + - name: APP_RATE_LIMIT_REDIS_COMMAND_TIMEOUT + type: duration + default: 1s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: spring_duration_shorthand_non_zero_le_30s + compatibility_impact: additive + required_test: rate-limit-contract:redis-timeout-bounded + + - name: APP_RATE_LIMIT_REDIS_MAXIMUM_COMMAND_BYTES + type: int + default: 16384 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: int_16384_to_65536 + compatibility_impact: additive + required_test: rate-limit-contract:redis-command-bytes-bounded + + - name: APP_RATE_LIMIT_REDIS_MAXIMUM_QUEUED_COMMANDS + type: int + default: 32 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: int_1_to_4096 + compatibility_impact: additive + required_test: rate-limit-contract:redis-queue-bounded + + - name: APP_RATE_LIMIT_REDIS_MAXIMUM_IN_FLIGHT_BYTES + type: int + default: 1048576 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: covers_rate_limit_command_and_le_268435456 + compatibility_impact: additive + required_test: rate-limit-contract:redis-byte-admission-bounded + + - name: APP_RATE_LIMIT_REDIS_NAMESPACE_ENVIRONMENT + type: string + default: local + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: lowercase_slug + compatibility_impact: additive + required_test: rate-limit-contract:redis-namespace-bounded + + - name: APP_RATE_LIMIT_POLICY_REVISION + type: string + default: v1 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: lowercase_slug + compatibility_impact: additive + required_test: rate-limit-contract:policy-revision-bounded + + - name: APP_RATE_LIMIT_ALGORITHM + type: enum + default: sliding-counter + allowed_values: [fixed-window, sliding-counter, token-bucket] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: enum_strict + compatibility_impact: additive + required_test: rate-limit-contract:algorithm-selectable + + - name: APP_RATE_LIMIT_LIMIT + type: int + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: positive_lua_exact_integer + compatibility_impact: additive + required_test: rate-limit-contract:limit-bounded + + - name: APP_RATE_LIMIT_WINDOW + type: duration + default: 1s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: spring_duration_shorthand_non_zero_le_1d + compatibility_impact: additive + required_test: rate-limit-contract:window-bounded + + - name: APP_RATE_LIMIT_CAPACITY + type: int + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: positive_lua_exact_integer + compatibility_impact: additive + required_test: rate-limit-contract:capacity-bounded + + - name: APP_RATE_LIMIT_REFILL_TOKENS + type: int + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: positive_lua_exact_integer + compatibility_impact: additive + required_test: rate-limit-contract:refill-tokens-bounded + + - name: APP_RATE_LIMIT_REFILL_PERIOD + type: duration + default: 1s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: spring_duration_shorthand_non_zero_le_1d + compatibility_impact: additive + required_test: rate-limit-contract:refill-period-bounded + + - name: APP_RATE_LIMIT_MAXIMUM_COST + type: int + default: 10 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: positive_lua_exact_integer + compatibility_impact: additive + required_test: rate-limit-contract:maximum-cost-bounded + + - name: APP_RATE_LIMIT_CLEANUP_GRACE + type: duration + default: 5s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: spring_duration_shorthand_non_zero_le_1d + compatibility_impact: additive + required_test: rate-limit-contract:cleanup-grace-bounded + + - name: APP_RATE_LIMIT_MAXIMUM_CLOCK_REGRESSION + type: duration + default: 250ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-distributed-rate-limit + validation: spring_duration_shorthand_non_negative_le_1h + compatibility_impact: additive + required_test: rate-limit-contract:clock-regression-bounded + - name: APP_IDEMPOTENCY_TTL # source: feature-rate-limit-idempotency-contract 2026-05-22 # "idempotency TTL default = 24h. long-running use case는 use case 선언으로 72h까지 override 가능" @@ -1226,8 +1582,129 @@ env_keys: compatibility_impact: behavior-change required_test: idempotency-contract:ttl-applied + - name: APP_IDEMPOTENCY_PROVIDER + type: enum + default: jdbc + allowed_values: [disabled, jdbc, redis] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: enum_strict + compatibility_impact: additive + required_test: redis-idempotency-contract:provider-exclusive + + - name: APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: base64_min_32_bytes_when_redis_idempotency_enabled + compatibility_impact: additive + required_test: redis-idempotency-contract:key-hmac-no-leak + + - name: APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT + type: string + default: local + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: lowercase_slug + compatibility_impact: additive + required_test: redis-idempotency-contract:key-namespace + + - name: APP_IDEMPOTENCY_PROCESSING_LEASE + type: duration + default: 30s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: positive_duration_le_1h + compatibility_impact: additive + required_test: redis-idempotency-contract:processing-lease-bounded + + - name: APP_IDEMPOTENCY_FAILURE_RETENTION + type: duration + default: 24h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: positive_duration_le_30d + compatibility_impact: additive + required_test: redis-idempotency-contract:failure-retention-bounded + + - name: APP_LEASE_PROVIDER + type: enum + default: disabled + allowed_values: [disabled, redis] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: enum_strict + compatibility_impact: additive + required_test: redis-lease-contract:provider-exclusive + + - name: APP_LEASE_REDIS_KEY_HMAC_SECRET + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: base64_min_32_bytes_when_redis_lease_enabled + compatibility_impact: additive + required_test: redis-lease-contract:key-hmac-no-leak + + - name: APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT + type: string + default: local + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: lowercase_slug + compatibility_impact: additive + required_test: redis-lease-contract:key-namespace + + - name: APP_LEASE_REDIS_DRIFT_BUDGET + type: duration + default: 10ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: non_negative_duration_lt_minimum_lease + compatibility_impact: additive + required_test: redis-lease-contract:drift-budget-bounded + # === Cache / Redis (feature-cache-consistency-contract + integration-adapter-templates) === + - name: APP_CACHE_CANONICAL_DEFAULT_PROVIDER + # Canonical default semantic region provider selection; legacy enable is a separate migration path. + type: enum + default: disabled + allowed_values: [disabled, redis] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: enum_strict + compatibility_impact: additive + required_test: redis-cache:canonical-cache-role-composition + - name: APP_CACHE_REDIS_ENABLED # source: feature-integration-adapter-templates 2026-05-22 # "Redis | disabled optional module | cache consistency" + Adapter Template Defaults 표 @@ -1292,6 +1769,54 @@ env_keys: compatibility_impact: additive required_test: secrets-contract:redis-password-no-leak + - name: APP_CACHE_REDIS_TRUST_PEM + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: non_blank_pem_when_cache_role_is_bound + compatibility_impact: additive + required_test: redis-contract:cache-trust-material-no-leak + + - name: APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL + type: duration + default: 5s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_1s_to_60s + compatibility_impact: additive + required_test: redis-contract:semantic-probe-cadence-bounded + + - name: APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD + type: duration + default: 30s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_5s_to_5m_inclusive + compatibility_impact: additive + required_test: redis-contract:sentinel-discovery-refresh-period-bounded + + - name: APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS + type: duration + default: 15s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_gte_probe_minimum_and_lte_5m + compatibility_impact: additive + required_test: redis-contract:semantic-probe-staleness-bounded + - name: APP_CACHE_REDIS_KEY_HMAC_SECRET type: string default: null @@ -1340,6 +1865,42 @@ env_keys: compatibility_impact: additive required_test: cache-contract:redis-command-byte-admission-bounded + - name: APP_CACHE_REDIS_POSITIVE_SOFT_TTL + type: duration + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-cache-resilience + validation: optional_spring_duration_non_zero_le_positive_hard_ttl + compatibility_impact: additive + required_test: cache-contract:redis-soft-hard-ttl-order + + - name: APP_CACHE_REDIS_TTL_JITTER + type: float + default: 0.10 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-cache-resilience + validation: decimal_0_to_0_5 + compatibility_impact: additive + required_test: cache-contract:redis-ttl-jitter-bounded + + - name: APP_CACHE_REDIS_MINIMUM_HARD_TTL + type: duration + default: 1s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-cache-resilience + validation: spring_duration_shorthand_non_zero_le_configured_hard_ttls + compatibility_impact: additive + required_test: cache-contract:redis-hard-ttl-minimum + - name: APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT type: string default: local @@ -1376,6 +1937,90 @@ env_keys: compatibility_impact: additive required_test: cache-contract:redis-value-size-bounded + - name: APP_CACHE_REDIS_L1_ENABLED + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: boolean_strict_cache_only + compatibility_impact: additive + required_test: cache-contract:redis-l1-disabled-default + + - name: APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES + type: int + default: 10000 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: int_1_to_1000000 + compatibility_impact: additive + required_test: cache-contract:redis-l1-cardinality-bounded + + - name: APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES + type: int + default: 67108864 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: int_1_to_1073741824_accounting_proxy + compatibility_impact: additive + required_test: cache-contract:redis-l1-weight-bounded + + - name: APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES + type: int + default: 1048576 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: positive_not_above_l1_maximum_weight + compatibility_impact: additive + required_test: cache-contract:redis-l1-entry-weight-bounded + + - name: APP_CACHE_REDIS_L1_TTL + type: duration + default: 30s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: spring_duration_shorthand_non_zero_le_1h + compatibility_impact: additive + required_test: cache-contract:redis-l1-ttl-bounded + + - name: APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL + type: duration + default: 5s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: spring_duration_shorthand_non_zero_le_l1_ttl + compatibility_impact: additive + required_test: cache-contract:redis-l1-generation-recheck-bounded + + - name: APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY + type: int + default: 1024 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability + validation: int_1_to_65536 + compatibility_impact: additive + required_test: cache-contract:redis-l1-invalidation-queue-bounded + - name: APP_CACHE_DEFAULT_TTL # source: feature-cache-consistency-contract 2026-05-22 # "TTL | explicit per key family | no-cache for sensitive data | immortal cache forbidden" @@ -1467,6 +2112,89 @@ env_keys: compatibility_impact: behavior-change required_test: adapter-contract:notification-email-provider-selection + # === Fileserver R2 local-persistent provider === + + - name: APP_FILESERVER_ENABLED + # source: Fileserver R2 control-plane/provider-selection design 2026-07-28. + # Disabled is the shipped safe default. Enabling requires every local attestation + # value below and an exact app.fileserver destination/provider graph. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-r2-control-plane-provider-selection + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-r2:disabled-default-and-enabled-attestation + + - name: APP_FILESERVER_LOCAL_ROOT + # Pre-provisioned local-persistent root. The runtime additionally attests real + # path, ancestor/root symlinks, owner/mode, FileStore, sentinel, and capabilities. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-r2-control-plane-provider-selection + validation: absolute_existing_directory_when_app_fileserver_enabled + compatibility_impact: additive + required_test: fileserver-r2:disabled-default-and-enabled-attestation + + - name: APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME + # Exact FileStore.name() expected for the pre-provisioned root. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-r2-control-plane-provider-selection + validation: non_empty_string_when_app_fileserver_enabled + compatibility_impact: additive + required_test: fileserver-r2:disabled-default-and-enabled-attestation + + - name: APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE + # Exact FileStore.type() expected for the pre-provisioned root. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-r2-control-plane-provider-selection + validation: non_empty_string_when_app_fileserver_enabled + compatibility_impact: additive + required_test: fileserver-r2:disabled-default-and-enabled-attestation + + - name: APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256 + # Lowercase SHA-256 of the operator-created .ca-fileserver-volume sentinel. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-r2-control-plane-provider-selection + validation: lowercase_sha256_when_app_fileserver_enabled + compatibility_impact: additive + required_test: fileserver-r2:disabled-default-and-enabled-attestation + + - name: APP_FILESERVER_LOCAL_EXPECTED_OWNER + # Exact filesystem owner expected for the attested root and private namespace. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-r2-control-plane-provider-selection + validation: non_empty_string_when_app_fileserver_enabled + compatibility_impact: additive + required_test: fileserver-r2:disabled-default-and-enabled-attestation + # === File / Upload (feature-file-resource-handling-contract) === - name: APP_FILE_UPLOAD_MAX_SIZE diff --git a/docs/registries/metrics.yaml b/docs/registries/metrics.yaml index 59b59d1..5057d97 100644 --- a/docs/registries/metrics.yaml +++ b/docs/registries/metrics.yaml @@ -433,6 +433,311 @@ metrics: compatibility_impact: additive required_test: contract-verification:metrics-cardinality + # source: redis-production-capability — optional bounded cache-only L1 + - name: cache.local.requests.total + type: counter + unit: total + tags: + - name: cache_name + cardinality_limit: 50 + - name: result + cardinality_limit: 4 + allowed_values: [hit, miss, error, bypass] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p3: "bypass or error rate above baseline for 15m" + owner_branch: redis-production-capability + log_field_mapping: [cache_name, result] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability — local stale-age bound + - name: cache.local.entry.age.seconds + type: timer + unit: seconds + tags: + - name: cache_name + cardinality_limit: 50 + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p3: "p99 approaches configured local TTL for 30m" + owner_branch: redis-production-capability + log_field_mapping: [cache_name] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability — bounded invalidation and generation reconciliation + - name: cache.local.maintenance.total + type: counter + unit: total + tags: + - name: cache_name + cardinality_limit: 50 + - name: event + cardinality_limit: 14 + allowed_values: + - evict_cardinality + - evict_weight + - evict_ttl + - evict_invalidation + - flush_invalidation + - reconcile_generation_changed + - reconcile_unchanged + - reconcile_error + - subscriber_disconnected + - subscriber_overflow + - subscriber_malformed + - subscriber_publish_success + - subscriber_publish_error + - other + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "reconcile_error, subscriber_overflow, or sustained disconnects for 5m" + owner_branch: redis-production-capability + log_field_mapping: [cache_name, event] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability Task 16 — closed semantic operation outcomes + - name: redis.capability.operations.total + type: counter + unit: total + tags: + - name: capability + cardinality_limit: 6 + allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime] + - name: role + cardinality_limit: 3 + allowed_values: [cache, coordination, session] + - name: operation + cardinality_limit: 24 + allowed_values: + - lookup + - record + - invalidate + - refresh_claim + - refresh_release + - rate_evaluate + - idempotency_claim + - idempotency_start + - idempotency_renew + - idempotency_complete + - idempotency_fail + - idempotency_release + - idempotency_inspect + - lease_acquire + - lease_inspect + - lease_renew + - lease_release + - session_create + - session_inspect + - session_save + - session_touch + - session_revoke + - session_rotate + - route_command + - name: redis_outcome + cardinality_limit: 15 + allowed_values: + - success + - hit + - miss + - denied + - contended + - conflict + - incompatible + - unavailable + - overloaded + - closed + - indeterminate + - stale + - skipped + - tombstoned + - absolute_expired + - name: certainty + cardinality_limit: 3 + allowed_values: [definite, not_applied, indeterminate] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "required coordination/session unavailable or indeterminate mutation sustained for 2m" + p2: "optional cache unavailable or overloaded above baseline for 5m" + owner_branch: redis-production-capability + log_field_mapping: [capability, role, operation, redis_outcome, certainty] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability Task 16 — monotonic semantic operation duration + - name: redis.capability.duration.seconds + type: timer + unit: seconds + tags: + - name: capability + cardinality_limit: 6 + allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session, runtime] + - name: role + cardinality_limit: 3 + allowed_values: [cache, coordination, session] + - name: operation + cardinality_limit: 24 + allowed_values: + - lookup + - record + - invalidate + - refresh_claim + - refresh_release + - rate_evaluate + - idempotency_claim + - idempotency_start + - idempotency_renew + - idempotency_complete + - idempotency_fail + - idempotency_release + - idempotency_inspect + - lease_acquire + - lease_inspect + - lease_renew + - lease_release + - session_create + - session_inspect + - session_save + - session_touch + - session_revoke + - session_rotate + - route_command + - name: redis_outcome + cardinality_limit: 15 + allowed_values: + - success + - hit + - miss + - denied + - contended + - conflict + - incompatible + - unavailable + - overloaded + - closed + - indeterminate + - stale + - skipped + - tombstoned + - absolute_expired + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p2: "p99 approaches the configured command or caller deadline for 10m" + owner_branch: redis-production-capability + log_field_mapping: [capability, role, operation, redis_outcome] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability Task 16 — admission rejected before command ownership + - name: redis.capability.admission.rejected.total + type: counter + unit: total + tags: + - name: role + cardinality_limit: 3 + allowed_values: [cache, coordination, session] + - name: admission + cardinality_limit: 2 + allowed_values: [rejected_saturated, rejected_closed] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "required role rejection sustained above zero for 2m" + p2: "optional cache saturation sustained for 5m" + owner_branch: redis-production-capability + log_field_mapping: [role, admission] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability Task 16 — bounded admitted command count observation + - name: redis.capability.inflight.total + type: gauge + unit: total + tags: + - name: role + cardinality_limit: 3 + allowed_values: [cache, coordination, session] + - name: state + cardinality_limit: 3 + allowed_values: [idle, active, saturated] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "saturated series remains nonzero for 5m" + owner_branch: redis-production-capability + log_field_mapping: [role, state] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability Task 16 — observations of exact sanitized RoleHealth + - name: redis.capability.readiness.total + type: counter + unit: total + tags: + - name: capability + cardinality_limit: 5 + allowed_values: [cache, rate_limit, idempotency, efficiency_lease, session] + - name: role + cardinality_limit: 3 + allowed_values: [cache, coordination, session] + - name: state + cardinality_limit: 3 + allowed_values: [available, unavailable, overloaded] + - name: reason + cardinality_limit: 11 + allowed_values: + - command_unavailable + - route_closed + - semantic_probe_succeeded + - semantic_read_write_failed + - semantic_program_acl_denied + - semantic_program_failed + - server_version_unsupported + - semantic_probe_in_progress + - semantic_observation_stale + - command_saturated + - recent_command_failure + - name: requirement + cardinality_limit: 2 + allowed_values: [optional, required] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "required coordination/session unavailable for 2m" + p2: "optional cache unavailable or overloaded for 5m" + owner_branch: redis-production-capability + log_field_mapping: [capability, role, state, reason, requirement] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: redis-production-capability Task 16 — bounded router shutdown drain result + - name: redis.capability.lifecycle.drain.total + type: counter + unit: total + tags: + - name: role + cardinality_limit: 3 + allowed_values: [cache, coordination, session] + - name: drain_outcome + cardinality_limit: 3 + allowed_values: [drained, forced_after_timeout, interrupted] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "required role forced_after_timeout or interrupted during shutdown" + p2: "optional cache forced close during shutdown" + owner_branch: redis-production-capability + log_field_mapping: [role, drain_outcome] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + # === Log appender === # source: feature-log-management-contract — Sampling Policy (final) # "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)" diff --git a/docs/registries/secrets-classification.yaml b/docs/registries/secrets-classification.yaml index 99a55ef..32d58c4 100644 --- a/docs/registries/secrets-classification.yaml +++ b/docs/registries/secrets-classification.yaml @@ -89,6 +89,18 @@ secrets: compatibility_impact: breaking required_test: secrets-contract:redis-password-no-leak + - name: APP_CACHE_REDIS_TRUST_PEM + # Public CA bundle content, but integrity-sensitive and supplied by the mounted environment. + classification: sensitive-config + source: mounted-env + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:redis-trust-reference-no-leak + - name: APP_CACHE_REDIS_KEY_HMAC_SECRET # Stable cache-key HMAC material. It is distinct from the Redis authentication credential. classification: secret @@ -101,6 +113,102 @@ secrets: compatibility_impact: breaking required_test: secrets-contract:redis-key-hmac-no-leak + - name: APP_RATE_LIMIT_REDIS_PASSWORD + # Dedicated coordination-role Redis credential. It is never inherited from cache Redis. + classification: secret + source: secret-manager + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-distributed-rate-limit + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:rate-limit-redis-password-no-leak + + - name: APP_RATE_LIMIT_REDIS_TRUST_PEM + # Coordination-role CA bundle content; integrity-sensitive but not credential material. + classification: sensitive-config + source: mounted-env + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:rate-limit-redis-trust-reference-no-leak + + - name: APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET + # Stable private-key derivation material for rate-limit subjects and policy revisions. + classification: secret + source: secret-manager + rotation_policy: dual-read-restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-distributed-rate-limit + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:rate-limit-redis-key-hmac-no-leak + + - name: APP_SESSION_REDIS_PASSWORD + # Dedicated session-role ACL credential; never shared implicitly with cache or coordination. + classification: secret + source: secret-manager + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:session-redis-password-no-leak + + - name: APP_SESSION_REDIS_TRUST_PEM + # Session-role CA bundle content; integrity-sensitive but not credential material. + classification: sensitive-config + source: mounted-env + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:session-redis-trust-reference-no-leak + + - name: APP_SESSION_REDIS_KEY_HMAC_SECRET + # Stable private derivation material for pseudonymous Redis session keys. + classification: secret + source: secret-manager + rotation_policy: dual-read-restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:session-redis-key-hmac-no-leak + + - name: APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET + # Owner-safe request-replay keys must not expose tenant/scope/request identifiers. + classification: secret + source: secret-manager + rotation_policy: cold-cutover-restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability-completion + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:idempotency-redis-key-hmac-no-leak + + - name: APP_LEASE_REDIS_KEY_HMAC_SECRET + # Efficiency-lease resource and owner scopes use a dedicated derivation key. + classification: secret + source: secret-manager + rotation_policy: cold-cutover-restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: redis-production-capability-completion + masking_rule: full + compatibility_impact: additive + required_test: secrets-contract:lease-redis-key-hmac-no-leak + - name: APP_PRIVACY_PSEUDONYMIZATION_SALT # source: feature-data-retention-privacy-contract 2026-05-22 # "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일. diff --git a/docs/runbooks/redis-capability-incident.md b/docs/runbooks/redis-capability-incident.md new file mode 100644 index 0000000..b6c1d19 --- /dev/null +++ b/docs/runbooks/redis-capability-incident.md @@ -0,0 +1,222 @@ +--- +title: Runbook — Redis capability incident +category: TRANSIENT_DEPENDENCY +error_codes: [] +severity: P1 +owner: oncall +last_updated: 2026-07-29 +status: active +--- + +# Runbook: Redis capability incident (`runbook://redis/capability-incident`) + +이 runbook은 Redis 전체를 하나의 상태로 취급하지 않는다. 먼저 영향받은 capability와 role을 +식별한다. + +| Role | Capability | 기본 안전 결정 | +| --- | --- | --- | +| `CACHE` | cache, cache refresh soft lease | source fallback 예산 안에서 degraded serving 허용 | +| `COORDINATION` | edge rate limit, request-replay idempotency, efficiency lease | 새 mutation/claim을 fail closed하고 결과 불확실성을 보존 | +| `SESSION` | Redis session | 인증을 fail open하지 않고 재인증 또는 503으로 전환 | + +Redis liveness 실패만으로 pod를 재시작하지 않는다. 재시작 폭주는 reconnect와 source fallback +부하를 키울 수 있다. + +## Detection + +- readiness detail에서 affected role과 `required` 여부를 확인한다. endpoint, key, token, secret + reference는 detail에 포함되면 안 된다. +- semantic reason을 구분한다: read/write failure, program ACL denial, unsupported server + version, program failure, command saturation, recent command failure, probe-in-progress, + stale observation, closed route, command unavailable. `semanticObservedAt`, + `semanticAgeMillis`, `semanticStale`를 함께 확인한다. PING 성공만으로 role이 ready라는 뜻은 + 아니다. +- `evictionValidation=CONFIGURED_EXPECTATION_ONLY`와 + `externalEvictionAttestation=INCOMPLETE`는 effective server policy가 증명되지 않았다는 + 뜻이다. 이를 정상 attestation으로 해석하지 않는다. +- `redis.capability.operations.total`과 `redis.capability.duration.seconds`에서 affected + capability/role/operation의 실제 반환 outcome을 확인한다. mutation의 + `certainty=indeterminate`는 timeout이나 연결 끊김을 미실행 증거로 바꾸지 않는다. +- `redis.capability.admission.rejected.total`에서 `rejected_saturated`와 + `rejected_closed`를 구분하고, `redis.capability.inflight.total`의 같은 role에 대해 현재 0이 + 아닌 state를 확인한다. in-flight gauge는 bounded command count이며 byte 수나 queue depth가 + 아니다. +- `redis.capability.readiness.total`은 현재 상태 gauge가 아니라 exact sanitized + `RoleHealth` 관측 횟수다. 최신 health detail의 state/reason/requirement와 함께 해석한다. + optional cache의 degraded serving과 required coordination/session의 fail-closed 결정을 + 같은 availability 의미로 합치지 않는다. +- 종료 시 `redis.capability.lifecycle.drain.total`에서 `drained`, + `forced_after_timeout`, `interrupted`를 구분한다. repeated close는 새 drain을 시작하거나 + 중복 관측을 만들지 않는다. +- reconnect, cache source-load, session repository error 지표의 변화를 함께 본다. +- Redis server 측에서는 memory/eviction, rejected clients, replication link/lag, + persistence error, Cluster coverage를 operator dashboard에서 확인한다. +- `NOSCRIPT`, result-schema mismatch, ACL denial, TLS/auth failure, OOM, timeout을 서로 다른 + incident category로 분류한다. timeout은 command 미실행 증거가 아니다. + +### Observability and lifecycle boundaries + +- 여섯 `redis.capability.*` meter의 tag는 닫힌 enum에서만 생성된다. key, subject, session id, + token, endpoint, exception text, script/SHA, value 같은 identity/wire material을 metric이나 + ticket에 복사하지 않는다. +- semantic operation 계측은 logical provider가 실제로 반환한 hit/miss/denied/conflict/ + unavailable/indeterminate 결과를 기록한다. cache의 `stale`/`skipped`, session의 + `tombstoned`/`absolute_expired`도 정상 hit/miss와 분리한다. meter registry, classifier, + monotonic ticker 장애는 command 결과나 원래 exception instance를 바꾸지 않는다. +- route 응답이 설정된 byte/collection bound를 넘으면 동일 logical operation을 + `unavailable`로 종료한다. GET/read-only 응답은 `not_applied`, mutation VALUE/MULTI 응답은 + 서버 실행 여부를 되돌릴 수 없으므로 `indeterminate`다. 앞선 `success` 표본과 이 실패를 두 + operation으로 합산하지 않는다. +- Spring 종료의 dependency order는 invalidation subscription 같은 capability dependent를 먼저 + 닫고, capability bean을 닫은 다음 canonical registry가 router admission을 닫아 in-flight를 + bounded drain하고 마지막에 runtime을 닫는 순서다. 종료 중 새 command를 허용하거나 drain + timeout 뒤 무기한 기다리지 않는다. +- 현재 composition에는 active Redis scheduler나 dormant credential-rotation coordinator가 없다. + 존재하지 않는 lifecycle coordinator를 복구 절차에서 찾거나 수동 호출하지 않는다. +- 이 meter와 단일-process lifecycle test는 Sentinel/Cluster failover, TLS/ACL 배포 적합성, + k3s multi-node, L1/L2 분산 일관성, distributed session 동작의 qualification 증거가 아니다. + 해당 label은 별도 topology/conformance lane의 실제 증거가 있어야 한다. + +## Immediate mitigation + +1. 새 배포나 credential/program 전환 직후라면 해당 rollout을 중지한다. 이미 실행된 mutation을 + 무조건 재시도하지 않는다. +2. optional cache만 영향을 받으면 source bulkhead와 stale/source fallback 예산을 확인한 뒤 + degraded serving을 유지한다. source가 포화되면 cache miss를 더 많은 source 요청으로 + 증폭시키지 않는다. +3. rate limit이 불확실하면 정책에 정의된 fail-closed 또는 bounded local-emergency만 사용한다. + local provider를 조용한 primary fallback으로 바꾸지 않는다. +4. idempotency claim/complete 응답이 유실됐으면 같은 operation token으로 inspect/reconcile한다. + record를 삭제하거나 새 owner를 추측하지 않는다. +5. lease 결과가 불확실하면 소유권이 있다고 가정하지 않는다. fencing 없는 efficiency lease를 + correctness lock으로 승격하지 않는다. +6. session repository 장애에서는 기존 요청을 인증된 것으로 간주하지 않는다. fail closed 또는 + 재인증으로 전환하고 JWT와 Redis Session filter를 동시에 활성화하지 않는다. + +## Diagnosis + +### Connectivity, TLS, ACL + +- 배포 설정이 올바른 role을 참조하고 TLS, hostname verification, explicit trust bundle, named ACL + user를 사용하는지 확인한다. +- runtime identity로 `CONFIG`, `KEYS`, `FLUSH*`, arbitrary program deployment를 시도하지 + 않는다. Catalog digest로 닫힌 recovery 외 ACL 점검은 별도 operator/deployer identity의 + `ACL DRYRUN` 또는 동등한 관리 절차로 수행한다. +- runtime readiness identity에는 bounded probe namespace `~ca-health:*`, SET/GET/DEL, + PING/EVALSHA와 catalog recovery에 필요한 SCRIPT LOAD, 그리고 선택 capability manifest의 exact + command set이 필요하다. broad `~*`/`+@all`로 장애를 우회하지 않는다. +- readiness probe는 5초 TTL의 opaque key만 사용한다. `ca-health:*` key가 5초를 넘겨 남는다면 + cleanup/expiry 이상으로 분류하되 key나 value를 ticket/log에 복사하지 않는다. +- 기본 semantic cadence는 minimum interval 5초, maximum staleness 15초다. refresh follower는 + blocking하지 않는다. maximum staleness를 넘은 관측을 backend 정상으로 해석하지 말고, + probe 부하를 줄이기 위해 interval을 1초 미만으로 낮추지 않는다. +- optional CACHE의 typed temporary connect/PING outage만 dormant degraded startup과 + health-triggered reconnect를 허용한다. reconnect 후보는 full semantic qualification 뒤에만 + 설치된다. auth/TLS/material/version/ACL/schema mismatch를 transient로 재분류하거나 required + role에 같은 fallback을 적용하지 않는다. +- credential rotation 중이라면 new credential 검증, traffic switch, old connection drain, + old credential revoke 순서를 확인한다. secret 값은 ticket, log, shell history에 복사하지 않는다. + +### Program or schema + +- checked-in program manifest digest와 배포 artifact digest를 대조한다. +- `semantic-capability-acl-v1` contract와 Redis minimum 7.2를 확인한다. 이 프로그램은 + Redis Lua API의 `redis.acl_check_cmd`로 선택 capability의 exact command/key 권한을 + 비변경 방식으로 검사하고 `redis.REDIS_VERSION_NUM`의 explicit >=7.2 gate를 먼저 적용한다. + 두 API는 7.0부터 존재하지만 repository support policy minimum은 7.2다. +- `NOSCRIPT`는 bounded `SCRIPT LOAD -> digest verify -> EVALSHA` recovery가 수행됐는지 확인한다. + arbitrary `EVAL`로 우회하지 않는다. +- result-schema/key/codec future version은 장애가 아니라 호환성 위반으로 분류하고 writer rollout을 + 중지한다. +- `BUSY` 또는 slow program이면 affected capability admission을 줄이고 isolated environment에서만 + 재현한다. shared Redis에 장시간 script를 추가 실행하지 않는다. + +### Memory and eviction + +- `CACHE` 배포와 `COORDINATION`/`SESSION` 배포가 물리적으로 분리됐는지 확인한다. +- correctness role에서 eviction이 관측되면 P1이다. 새 write를 중지하고 record loss를 전제로 + idempotency/session reconciliation 또는 재인증 범위를 산정한다. +- noeviction OOM은 성공으로 변환하지 않는다. cache write는 degraded/indeterminate, coordination + mutation은 unavailable/indeterminate로 유지한다. +- big key를 찾을 때 production request path에서 `KEYS`나 unbounded collection read를 사용하지 + 않는다. 승인된 operator job의 bounded `SCAN`/sampling을 사용한다. + +### Topology and persistence + +- 현재 구현 후보 card의 promotion topology는 readiness registry의 `selected-topology`가 정본이다. + 이는 selection 또는 R2 qualification을 뜻하지 않는다. Sentinel/Cluster evidence가 없는 + 상태에서 standalone 증거를 HA 증거로 재사용하지 않는다. +- Cluster same-slot semantic probe는 해당 hash slot owner 한 노드만 검증한다. 이를 cluster-wide + 또는 failover target version/ACL/program 증거로 해석하지 말고, promotion 전에 모든 target을 + 별도 conformance lane으로 검증한다. +- failover 뒤에는 in-flight mutation의 certainty, primary role, program availability, replication + offset/lag, persistence status를 각각 확인한다. +- restore 후 session/idempotency/lease record를 자동으로 신뢰하지 않는다. security epoch, + tombstone, durable receipt/fencing high-watermark가 필요한 capability는 별도 reconciliation을 + 수행한다. + +### Sentinel failover + +1. affected role의 semantic readiness가 unavailable인지 확인하고 단순 PING success로 정상 판정하지 + 않는다. required coordination/session은 새 mutation admission을 닫는다. +2. 세 Sentinel 중 응답 수와 같은 master에 동의한 수를 확인한다. 2-of-3 동의 전에는 임의 endpoint, + 최초 응답 또는 DNS 추측으로 data runtime을 바꾸지 않는다. +3. Sentinel discovery credential/CA와 Redis data credential/CA가 분리되어 있는지 확인한다. + 장애 우회를 위해 trust-all, hostname verification off, plaintext 또는 broad ACL을 열지 않는다. +4. election, discovered primary qualification, new runtime install, old runtime admission close/drain의 + 순서를 확인한다. old runtime을 강제로 닫아야 했다면 그 시점의 mutation을 성공/미실행으로 + 추정하지 않는다. +5. response-only cut, timeout, disconnect가 있었던 rate/idempotency/session mutation은 + `INDETERMINATE`를 보존한다. rate evaluation replay, 같은 idempotency/session operation token의 + inspect/reconcile 또는 재인증을 사용하고 blind retry하지 않는다. +6. semantic readiness 복구 전에는 traffic을 정상화하지 않는다. 복구 뒤 old primary의 replica + 재합류, replication lag/acknowledgement, program digest, actor runtime generation을 확인한다. + +Sentinel은 asynchronous replication의 zero-data-loss나 strong consistency를 보장하지 않는다. +`min-replicas-to-write`, lag bound, replica acknowledgement가 설정돼도 acknowledgement 결과가 +불명확한 mutation은 여전히 `INDETERMINATE`다. + +### Disposable Multipass k3s qualification safety + +qualification lab은 host k3s incident 조치 도구가 아니다. VM exact allowlist는 +`ca-redis-lab-server`, `ca-redis-lab-agent-1`, `ca-redis-lab-agent-2`이며 전용 kubeconfig와 +`ca-redis-lab` context만 사용한다. + +- 시작 전 host context/API/node/CIDR/NodePort와 Multipass inventory fingerprint를 기록한다. +- lab pod/service CIDR `10.52.0.0/16`, `10.53.0.0/16`이 host와 겹치면 생성하지 않는다. +- default kubeconfig를 merge/overwrite하거나 host context에 write command를 실행하지 않는다. +- cleanup은 exact 세 VM만 대상으로 한다. global `multipass purge`, wildcard delete를 사용하지 + 않는다. +- 성공/실패 뒤 postflight fingerprint와 VM resource 0을 확인한다. local retain-on-failure가 + 명시적으로 활성화됐으면 보존 이유와 exact inventory를 기록하며 CI에서는 보존하지 않는다. +- 이 한 물리 host의 3 VM 결과를 k3s control-plane HA, physical host/AZ failure 또는 + multi-region 증거로 승격하지 않는다. + +## Recovery and verification + +1. affected role의 connection/auth/TLS와 `ca-health:` SET/GET/cleanup probe가 정상인지 + 확인한다. probe 잔여 key가 있으면 최대 TTL 5초 뒤 소멸하는지도 확인한다. +2. 선택 capability의 대표 program digest/result schema, semantic ACL contract와 Redis minimum + version 7.2를 재확인한다. +3. capability별 smoke를 수행한다: cache generation guarded write, rate evaluation replay, + idempotency same-operation inspect, lease stale-owner reject, session create/read/logout. +4. queue saturation, indeterminate outcome, source fallback, re-auth 지표가 incident 전 범위로 + 돌아온 뒤에만 rollout을 재개한다. +5. `CONFIGURED_EXPECTATION_ONLY`인 eviction은 operator/deployer identity의 외부 conformance + job 또는 서명 attestation으로 effective policy를 별도 검증한다. runtime user에 CONFIG/ACL + 권한을 추가하지 않는다. +6. production label을 변경하기 전 repository readiness task를 실행한다. Sentinel/Cluster task가 + zero-evidence로 실패한다면 topology를 낮춰 표기하거나 실제 evidence를 먼저 추가한다. +7. Sentinel qualification에서는 actual image ID/digest와 fault/election/runtime-swap/readiness + timeline, capability certainty, teardown 결과가 sanitizer/reconciler를 통과했는지 확인한다. + clean committed source와 실제 remote CI가 없으면 `implemented-candidate`, + `releaseQualification=NOT_CLAIMED`를 유지한다. + +## Escalation + +- `COORDINATION` 또는 `SESSION` required role이 5분 이상 unavailable이면 P1로 Redis/platform, + application on-call을 동시에 호출한다. +- data loss, stale session resurrection, conflicting idempotency completion, duplicate correctness + side effect가 의심되면 security/business owner까지 즉시 확대한다. +- 한 물리 host의 VM 세 개 또는 standalone container 결과를 AZ/host failure 증거로 해석하지 + 않는다. 그 증거가 필요한 release는 별도 disposable multi-node qualification을 요구한다. diff --git a/docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md b/docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md new file mode 100644 index 0000000..801794c --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md @@ -0,0 +1,882 @@ +# Fileserver R2 Control Plane and Provider Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox +> (`- [ ]`) syntax for tracking. Repository policy is `human-only`: do not stage, commit, amend, or +> push. + +**Goal:** Add an explicit provider-neutral Fileserver R2 control plane and qualify +`local-persistent` as the first provider without making local filesystem the production default. + +**Architecture:** `application-core` keeps the existing `FilePublicationPort` and gains only one +provider-neutral achieved-durability value. The fileserver leaf compiles `app.fileserver` +destination/provider settings into an exact registry, routes requests through one port bean, and +coordinates versioned operation, manifest, and reference records. A strict +`local-persistent` provider attests its root before use and advances the durable publication state +machine in forced, recoverable steps. + +**Tech Stack:** Java 21, Spring Boot 4 configuration properties/autoconfiguration, JDK NIO/POSIX, +JUnit 5, AssertJ, ApplicationContextRunner, Gradle quality gates. + +--- + +### Task 1: Add the provider-neutral achieved durability + +**Files:** +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java` +- Modify: + `src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java` + +- [x] **Step 1: Write the failing contract test** + +Add a test that constructs a receipt with the new achieved value and proves no provider or path type +is introduced: + +```java +@Test +void receiptCanReportFileAndDirectorySyncWithoutExposingAProviderType() { + FilePublishReceipt receipt = + receiptWith(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + + assertThat(receipt.durabilityGuarantee()) + .isEqualTo(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + assertThat(FilePublishReceipt.class.getDeclaredFields()) + .allSatisfy(field -> assertThat(field.getType().getName()) + .doesNotContain("java.nio.file", "fileserver", "sftp")); +} +``` + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain +``` + +Expected: compilation failure because `FILE_AND_DIRECTORY_SYNC` does not exist. + +- [x] **Step 3: Implement the minimum contract change** + +Add only this enum member: + +```java +public enum DurabilityGuarantee { + PROCESS_LOCAL_SYNC, + FILE_AND_DIRECTORY_SYNC, + PROVIDER_ACK_ONLY +} +``` + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +--- + +### Task 2: Compile exact destination/provider settings with no local fallback + +**Files:** +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java` + +- [x] **Step 1: Write failing exact-binding tests** + +Cover: + +```java +@Test +void enabledSettingsRequireAnExplicitDestinationAndProvider() { + assertThatThrownBy(() -> FileserverBindingCompiler.compile(enabled(Map.of(), Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destination"); +} + +@Test +void rejectsUnknownOrUnimplementedProviderTypes() { + assertThatThrownBy(() -> compile("shared-mounted")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("local-persistent"); +} + +@Test +void compilesOnlyAnExactLocalPersistentBinding() { + Map result = + FileserverBindingCompiler.compile(validSettings()); + + assertThat(result).containsOnlyKeys(new FileDestinationId("local-export")); + assertThat(result.get(new FileDestinationId("local-export")).providerId()) + .isEqualTo("local-primary"); +} +``` + +Also reject blank IDs, unknown `provider-ref`, duplicate normalized IDs, non-absolute root, enabled +`auto-create`, unsupported publication/durability values, and non-positive row/byte bounds. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*FileserverBindingCompilerTest' --console=plain +``` + +Expected: compilation failure because the settings/compiler do not exist. + +- [x] **Step 3: Implement typed settings** + +Use one public configuration-properties record: + +```java +@ConfigurationProperties(prefix = "app.fileserver") +public record FileserverR2Settings( + boolean enabled, + Map destinations, + Map providers) { + + public record DestinationSettings( + String providerRef, + String requiredPublication, + String requiredDurability, + long maximumRows, + long maximumEncodedBytes) {} + + public record ProviderSettings( + String type, + String rootDirectory, + boolean autoCreate, + boolean strictPathSecurity, + String expectedFileStoreName, + String expectedFileStoreType, + String mountSentinelName, + String mountSentinelSha256, + String expectedOwner, + String maximumRootMode) {} +} +``` + +The compiler accepts exactly: + +```text +type=local-persistent +required-publication=unique-atomic-create +required-durability=file-and-directory-sync +auto-create=false +strict-path-security=true +``` + +`CompiledFileDestination` contains validated application destination ID, provider ID, absolute +root, limits, root attestation inputs, and no Spring type. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +--- + +### Task 3: Attest a pre-provisioned persistent root + +**Files:** +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java` + +- [x] **Step 1: Write failing attestation tests** + +Create a real POSIX temporary root and sentinel. Test successful evidence and each fail-closed +condition: + +```java +@Test +void attestsOwnerModeStoreSentinelSecureDirectoryAndSyncPrimitives() { + CompiledFileDestination destination = destinationFor(attestedRoot()); + + LocalPersistentRootEvidence evidence = + new LocalPersistentRootAttestor().attest(destination); + + assertThat(evidence.root()).isEqualTo(root.toRealPath()); + assertThat(evidence.secureDirectoryStream()).isTrue(); + assertThat(evidence.directorySync()).isTrue(); + assertThat(evidence.exclusiveHardLink()).isTrue(); +} +``` + +Separate tests reject: + +- relative or missing root; +- symlink root/ancestor; +- owner mismatch; +- group/world-writable root; +- FileStore name/type mismatch; +- missing, symlinked, non-regular, or digest-mismatched sentinel; +- staging/data/control on a different FileStore; +- unavailable `SecureDirectoryStream`, hard-link, or directory-force probe. + +Probe collaborators may be package-private injectable functions so negative paths do not depend on +the host filesystem lacking a feature. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*LocalPersistentRootAttestorTest' --console=plain +``` + +Expected: compilation failure because attestation types do not exist. + +- [x] **Step 3: Implement strict attestation** + +The attestor must: + +```text +reject before creating anything when root/sentinel/owner/mode/store mismatch +capture root real path, file key, FileStore name/type, sentinel digest +create private .ca-fileserver, data, staging, operations, manifests, references, probe directories +set newly-created directories to 0700 +force each created parent directory +open a SecureDirectoryStream on root +run unique exclusive-create + force + hard-link + directory-force probe +delete probe artifacts and force the probe directory +return immutable evidence used for pre/post identity checks +``` + +Do not silently downgrade to R1. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS on the supported Linux/POSIX lane. + +--- + +### Task 4: Add strict reference, journal-v2, manifest, and reference records + +**Files:** +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java` + +- [x] **Step 1: Write failing codec tests** + +Test: + +```java +@Test +void referenceRoundTripRejectsForgeryUnknownRouteAndTruncation() { + PublishedFileReference reference = codec.encode("routea1", fixedFileId()); + + assertThat(codec.decode(reference, Set.of("routea1")).fileId()).isEqualTo(fixedFileId()); + assertThatThrownBy(() -> codec.decode(tamper(reference), Set.of("routea1"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> codec.decode(reference, Set.of("routeb2"))) + .isInstanceOf(IllegalArgumentException.class); +} +``` + +For all three records prove: + +- canonical encode/decode round trip; +- maximum encoded length; +- exact schema version; +- state and revision invariants; +- single-segment internal locators; +- lowercase SHA-256 fields; +- no absolute path, raw row/cell, credential, URI, or control character; +- newer schema and duplicate/unknown fields fail closed. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*FileserverControlRecordCodecTest' --console=plain +``` + +Expected: compilation failure because R2 records/codecs do not exist. + +- [x] **Step 3: Implement bounded canonical records** + +Use a strict flat canonical JSON codec owned by this leaf. The record state is: + +```java +enum State { + WRITING, + SEALED, + DATA_PUBLISHED, + MANIFEST_PUBLISHED, + REFERENCE_PUBLISHED, + PUBLISHED, + QUARANTINED +} +``` + +`R2PublishedReferenceCodec` uses: + +```text +fsr1..<32-lower-hex-file-id>. +``` + +The check digits detect corruption only and are not authentication. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +--- + +### Task 5: Persist forced control records and operation locks + +**Files:** +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java` + +- [x] **Step 1: Write failing control-plane tests** + +Test direct lookup and forced revision handling: + +```java +@Test +void storesAndDirectlyLoadsOperationManifestAndReferenceRecords() { + controlPlane.storeOperation(writingRecord()); + controlPlane.storeManifest(manifest()); + controlPlane.storeReference(referenceRecord()); + + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + assertThat(controlPlane.findManifest(FILE_ID)).contains(manifest()); + assertThat(controlPlane.findReference(FILE_ID)).contains(referenceRecord()); +} +``` + +Also prove: + +- lower/equal incompatible state revision is rejected; +- request fingerprint mismatch is conflict; +- temp file is force-written before atomic replace; +- target parent is forced after replace; +- shard creation forces its parent; +- symlink shard/record is rejected with `NOFOLLOW_LINKS`; +- reads, temporary creation, stat, and delete use attested directory-relative names through + `SecureDirectoryStream`; operations without a portable secure hard-link/flagged atomic-replace + overload remain limited to the private-owner root and require pre/post identity checks; +- same operation is serialized by JVM stripe plus OS `FileLock`; +- record corruption is never treated as absent. + +Use a package-private fault-point callback to observe/throw at: + +```text +TEMP_FORCED +RECORD_REPLACED +PARENT_FORCED +``` + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*LocalPersistentControlPlaneTest' --console=plain +``` + +Expected: compilation failure because the control plane does not exist. + +- [x] **Step 3: Implement durable storage** + +All writes follow: + +```text +CREATE_NEW sibling temp +write all bytes +FileChannel.force(true) +ATOMIC_MOVE + REPLACE_EXISTING for the control record only +force parent directory +read-back and verify identity/revision/digest +``` + +Payload publication must never use overwrite-capable move. Control record replacement is safe only +under the operation lock and monotonically increasing `stateRevision`. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +--- + +### Task 6: Implement the local-persistent R2 provider and deterministic recovery + +**Files:** +- Modify: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java` +- Modify: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java` +- Modify: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java` +- Modify: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java` + +- [x] **Step 1: Write failing publication-order tests** + +First add failing compiler/control-plane assertions for: + +```text +deterministic route token = "r" + first 31 lowercase hex of canonical policy digest +same startup allowlist route-token collision -> startup failure +length-prefixed effective policy/schema/format digest stability +same secure operation lookup -> typed canonical v1 or v2 +v1 is read-only; malformed UTF-8/non-canonical/newer schema is indeterminate, never absent +control fault context identifies record kind, identity, +applicable operation state/revision, and force boundary +``` + +Then use a deterministic file ID/clock and a fault recorder. Prove exact order: + +```text +J_WRITING +STAGE_FORCED +J_SEALED +DATA_LINKED +DATA_DIRECTORY_FORCED +J_DATA_PUBLISHED +MANIFEST_FORCED +J_MANIFEST_PUBLISHED +REFERENCE_FORCED +J_REFERENCE_PUBLISHED +J_PUBLISHED +``` + +Verify the receipt has an opaque `fsr1` reference, +`UNIQUE_ATOMIC_CREATE`, and `FILE_AND_DIRECTORY_SYNC`. + +Also test producer once, streaming bounds, formula mitigation, target collision no overwrite, +root-identity change indeterminate, and manifest/reference locator non-disclosure. The stored +`internalLocator` is the generated filename only; its data shard is derived from the first two +hex characters of `fileId`. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*FileserverBindingCompilerTest' \ + --tests '*LocalPersistentControlPlaneTest' \ + --tests '*LocalPersistentPublicationProviderTest' --console=plain +``` + +Expected: compilation/test failure because the compiled identity, typed compatibility lookup, +contextual fault seam, payload operations, and provider do not exist. + +- [x] **Step 3: Implement prerequisites and minimal R2 publication** + +Compile one restart-stable destination identity without adding a config key: + +```text +effectivePolicyDigest = SHA-256(length-prefixed canonical descriptor fields) +routeToken = "r" + first 31 lowercase hex of effectivePolicyDigest +``` + +The canonical descriptor includes destination/provider IDs, limits, required guarantees, and the +format/encoder revision. The schema and format policy use the same length-prefixed digest helper. +Reject route-token collisions across the compiled startup allowlist. Keep digest/token derivation +on the production SHA-256 path only. Exercise the otherwise impractical collision branch through +the same package-private pure route-registry check used by production, using two different test +digests whose first 31 hex characters collide; expose no digest/token runtime override. + +Extend `LocalPersistentControlPlane` with one secure relative typed operation lookup. It returns +schema-v1 only through strict UTF-8 plus canonical v1 re-encode byte equality and never writes v1; +schema-v2 remains the only write format. Enrich its package-private fault callback with record kind, +identity, operation state/revision, and force boundary so Task 8 can stop at an exact record force. + +The provider: + +```text +validates destination and request before producer invocation +acquires operation lock +loads operation by direct ID +allocates fileId/name before WRITING +streams with existing StreamingCsvEncoder +forces stage and stores SEALED +exclusive hard-links data and forces data directory +publishes private manifest +publishes reference index +stores terminal receipt snapshot +returns only after terminal journal parent force/read-back +``` + +`LocalPersistentPayloadOperations` owns restrictive staging/data shard creation, secure relative +stage create/write/force, stable no-follow artifact inspection/digest, exact stage deletion, +exclusive no-replace hard-link, standalone recovery-time data-shard directory force, and +attested-root-relative R1 artifact inspection. Absolute hard-link/directory-force calls are allowed +only inside the attested private-owner boundary with file/root/directory identity checks. An +existing matching data artifact discovered from `SEALED` must have its shard directory forced +again before the journal may advance; it is never republished through a collision path. A +root-level R1 artifact is restored only after bounded SDS-relative no-follow inspection matches the +terminal R1 journal. + +Before and after the hard-link commit, compare root real path, file key, FileStore, and sentinel +digest to `LocalPersistentRootEvidence`. + +- [x] **Step 4: Write failing recovery matrix tests** + +For every non-terminal state construct matching/missing artifacts and retry with a producer that +throws if called. Expected: + +```text +SEALED + stage -> resume data publish +SEALED + matching data -> resume manifest +DATA_PUBLISHED -> resume manifest +MANIFEST_PUBLISHED -> resume reference +REFERENCE_PUBLISHED -> finish terminal journal +PUBLISHED + all matching -> restore exact receipt +non-terminal data/manifest/reference mismatch -> QUARANTINED / integrity failure +PUBLISHED artifact/metadata/receipt mismatch -> preserve all terminal evidence; integrity / indeterminate +required artifact missing -> fail-closed indeterminate / quarantine, never success +fingerprint mismatch -> CONFLICT +root identity mismatch -> PUBLISH_INDETERMINATE +WRITING producer/stage failure -> exact cleanup + unsealed QUARANTINED +retry with existing WRITING -> producer is not invoked; indeterminate / quarantine +retry of unsealed QUARANTINED -> producer is not invoked +``` + +`LocalPersistentRecoveryVerifier` must cross-check the operation, incoming request, stable data +digest, canonical manifest/reference digests, all locators/counts/timestamps, and guarantees. +Because operation schema v2 does not carry a standalone format-policy snapshot, it must require an +exact current compiled effective-policy revision/digest match before using the current +format-policy digest; it must fail closed instead of guessing across an encoder-policy change. +Current configured byte/row limits apply to a new attempt. Recovery inspection is bounded by the +already frozen operation byte size (with overflow-safe equality), so a later lower configuration +limit does not reinterpret a sealed artifact. If both stage and data exist, their stable file keys +must match before exact stage deletion; equal bytes alone are insufficient. +Restore a terminal receipt only when it equals the full receipt reconstructed from the verified +manifest/reference; checking only operation ID/count/SHA is insufficient. Reuse a verified +immutable manifest/reference `publishedAt` after a crash instead of generating a conflicting time. +`QUARANTINED` journal transitions are limited to non-terminal operations. A mismatch discovered +from `PUBLISHED` must not replace the terminal journal or delete/overwrite data, manifest, or +reference records; return typed integrity/indeterminate and preserve all terminal evidence. A +separate immutable quarantine incident record is outside this increment. + +- [x] **Step 5: Write failing R1 compatibility tests** + +Pre-provision an existing R1 root so it passes every R2 root attestation condition, then configure +that same root as the R2 destination. Place a valid journal schema-v1 terminal record at the shared +hashed operation path and a matching root-level R1 artifact. +The R2 reader may restore its original `PROCESS_LOCAL_SYNC` receipt, but must not create an R2 +manifest/reference, change its guarantee, or rewrite the record as schema v2. Newer/corrupt R1 +records remain indeterminate. Also prove malformed UTF-8 and a decodable but non-canonical v1 +encoding fail, and that simultaneous R1/R2 bean activation is not required for migration. + +- [x] **Step 6: Verify compatibility RED, then implement read-only compatibility** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*LocalPersistentPublicationRecoveryTest' --console=plain +``` + +Expected before implementation: the R1 restoration assertion fails. Reuse the existing schema-v1 +model/codec behind an added strict UTF-8 and canonical re-encode equality guard, only as a read-only +compatibility reader; do not add schema-v1 write paths or an unconfigured second root. + +- [x] **Step 7: Verify recovery RED, then implement recovery** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*LocalPersistentPublicationRecoveryTest' --console=plain +``` + +Expected before recovery implementation: failures at each resume assertion. Implement only the +matrix and verifier rules above. When producer or staging fails after `J_WRITING`, preserve the +original exception, attach cleanup/control failures as suppressed, exact-delete the partial stage, +and store unsealed `QUARANTINED` evidence so retry cannot replay the producer. A retry that finds +`WRITING` after a process crash also must not invoke the producer. Then rerun. Expected: PASS. + +- [x] **Step 8: Verify provider GREEN** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*LocalPersistentPublicationProviderTest' \ + --tests '*LocalPersistentPublicationRecoveryTest' --console=plain +``` + +Expected: PASS. + +--- + +### Task 7: Add one routing port bean and reject ambiguous R1/R2 activation + +**Files:** +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java` +- Create: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java` +- Test: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java` +- Modify: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java` +- Rename: + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java` + to + `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java` +- Modify: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java` +- Modify: + `src/app-bootstrap/build.gradle` +- Modify: + `src/config/architecture/modules.json` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/OptionalAdapterBeanGatingTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java` + +- [x] **Step 1: Write failing composition/routing tests** + +Prove: + +```java +@Test +void disabledR2CreatesNoPortOrFilesystemSideEffect() {} + +@Test +void enabledR2CreatesExactlyOneRoutingPortForExplicitBindings() {} + +@Test +void requestForUnknownDestinationFailsBeforeProducerInvocation() {} + +@Test +void enablingLegacyR1AndR2TogetherFailsStartup() {} + +@Test +void configuredButUnimplementedSharedOrSftpProviderFailsStartup() {} +``` + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*FileserverR2ConfigTest' --console=plain +``` + +Expected: compilation/test failure because R2 composition does not exist. + +Execution note: the production composition skeleton had already been introduced before the +delegated test task returned, so a standalone RED Gradle run was no longer reproducible without +reverting work. The tests still exposed the missing method-level conditional gate through the +bootstrap architecture check; that failure was observed and fixed before GREEN. + +- [x] **Step 3: Implement exact routing composition** + +`RoutingFilePublicationAdapter` contains an immutable +`Map` and delegates only after exact lookup. +`FileserverR2Config`: + +- is conditional on `app.fileserver.enabled=true`; +- enables `FileserverR2Settings`; +- compiles and attests every configured binding at startup; +- creates one provider instance per provider ID; +- creates exactly one `FilePublicationPort`; +- rejects `ca-skeleton.fileserver.enabled=true` in the same environment before either R1 root + creation or R2 attestation, independently of Spring bean creation order; +- rejects different provider IDs that resolve to the same normalized root; +- never creates directories/connections when disabled. + +The same package-private activation validator runs first in both R1 bean factories and the R2 +routing factory; conditional precedence is not an acceptable substitute for an ambiguity failure. +Use strict configuration-properties binding (`ignoreUnknownFields = false`). Wire the fileserver +leaf into `app-bootstrap` through the architecture registry and Gradle dependency in this task so +the runtime composition is real, while keeping all local provider/control types private to the +leaf. Rename the legacy configuration-properties type to the repository-required `*Settings` +suffix before exposing this leaf to bootstrap naming checks. + +- [x] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +--- + +### Task 8: Add process-crash qualification, docs, and full gates + +**Files:** +- Create: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java` +- Create: + `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java` +- Modify: `src/adapter/outbound/fileserver/README.md` +- Modify: `src/adapter/outbound/fileserver/CLAUDE.md` +- Modify: + `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` +- Modify: + `docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md` +- Modify: + `docs/superpowers/plans/2026-07-28-fileserver-r2-control-plane-provider-selection.md` +- Modify: `docs/registries/env-keys.yaml` + +- [x] **Step 1: Write the failing forked-process crash test** + +Launch a new JVM with the test runtime classpath. The helper receives a fault point and calls +`Runtime.getRuntime().halt(91)` immediately after that point. Cover: + +```text +J_WRITING +STAGE_FORCED +J_SEALED +DATA_LINKED +DATA_DIRECTORY_FORCED +MANIFEST_FORCED +MANIFEST_DIRECTORY_FORCED +REFERENCE_FORCED +REFERENCE_DIRECTORY_FORCED +TERMINAL_JOURNAL_FORCED +TERMINAL_JOURNAL_DIRECTORY_FORCED +``` + +Restart in a second JVM/process and assert exact receipt restoration or a documented typed +indeterminate/quarantine outcome, never producer replay or partial final bytes. + +Also run a forked cross-process operation-lock proof using the same attested root and operation ID: +process A acquires and reports the OS lock, process B uses a bounded non-blocking/timed attempt and +must not enter the critical section while A is alive, then must acquire after A releases or is +forcibly terminated. This proof must exercise the OS `FileLock`; the same-JVM stripe test is not a +substitute and every wait requires a timeout. + +- [x] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:fileserver:test \ + --tests '*LocalPersistentCrashRecoveryTest' --console=plain +``` + +Expected: failure until every fault point is injectable and recoverable. + +Execution note: the contextual control-plane and payload fault seams introduced in Task 6 already +covered all eleven boundaries. The first complete forked-process run therefore passed without a +new production hook; no implementation was reverted merely to manufacture a RED result. + +- [x] **Step 3: Implement only missing fault hooks/recovery transitions** + +Fault hooks remain package-private test collaborators. No runtime setting or production bean may +allow arbitrary process termination. + +- [x] **Step 4: Verify focused and module checks** + +Run: + +```bash +cd src +./gradlew :application-core:check :adapter:outbound:fileserver:check --console=plain +``` + +Expected: PASS. + +- [x] **Step 5: Update readiness documentation** + +Record: + +- provider-neutral control plane and exact selector implemented; +- `local-persistent` is the only qualified R2 provider; +- `FILE_AND_DIRECTORY_SYNC` does not claim physical device power-loss protection; +- `shared-mounted`, SFTP, reaper/retention/quota/observability remain unimplemented; +- R1 compatibility artifacts are never auto-promoted. + +Register the exact local provider environment keys from the design (`ROOT`, expected FileStore +name/type, sentinel digest, expected owner) with restart-only policy and conditional +`app.fileserver.enabled` validation. Do not add SFTP/NFS keys before those providers exist. + +- [x] **Step 6: Run full repository gates** + +Run: + +```bash +cd src +./gradlew check --console=plain +./gradlew \ + :application-core:verifyDependencyLocks \ + :adapter:outbound:fileserver:verifyDependencyLocks \ + :app-bootstrap:verifyDependencyLocks \ + :sample-portfolio:verifyDependencyLocks \ + verifyCleanArchitectureDependencies \ + verifyPublicPathSnapshot \ + verifyEnvKeys --console=plain +git diff --check +``` + +Expected: all commands PASS. + +- [x] **Step 7: Request final independent review** + +Review against: + +- the R2 design spec; +- HARD-STOP rules; +- provider fallback/activation ambiguity; +- path/symlink/mount identity; +- crash ordering and recovery; +- receipt guarantee truthfulness; +- R1 compatibility and no unrelated adapter dependency. + +Fix every Critical/Important issue and rerun the affected focused test plus full gates. diff --git a/docs/superpowers/plans/2026-07-28-httpclient-canonical-zero-binding.md b/docs/superpowers/plans/2026-07-28-httpclient-canonical-zero-binding.md new file mode 100644 index 0000000..8b129d7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-httpclient-canonical-zero-binding.md @@ -0,0 +1,120 @@ +# HTTP Client Canonical Zero-Binding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` or `superpowers:executing-plans`. Repository policy +> overrides the skill's commit steps: do not stage, commit, amend, or push. + +**Goal:** Make HTTP client activation an explicit canonical composition decision and prove that the +default zero-binding state creates no client, executor, shutdown guard, retry/circuit-breaker +registry, or transport resource. + +**Architecture:** `adapter:outbound:httpclient` owns strict canonical configuration, immutable +binding/provider/catalog/readiness registries, and a pure activation resolver. `app-bootstrap` owns +the composition root that binds canonical properties and publishes an inert capability descriptor. +The existing JDK `OutboundHttpClient` remains an explicitly constructed R1 migration facade; its +legacy settings and infrastructure configuration must no longer be discovered automatically. + +**Scope boundary:** This increment does not add Apache HC5, a provider factory, a real semantic +upstream binding, hard wire cancellation, TLS/DNS/proxy/auth, or an R2 readiness claim. Every current +ACTIVE selection must fail closed because the only derived readiness card remains +`NOT_IMPLEMENTED`. + +--- + +### Task 1: Add strict canonical selection and provider binding models + +**Files:** +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java` +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java` +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java` +- Test: + `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java` + +- [x] Write RED tests for the canonical YAML shape under + `ca-skeleton.capabilities.http-client` and `ca-skeleton.providers.http-client`. +- [x] Reject unknown fields, malformed IDs, unknown expected state, and any legacy input entering + canonical composition, including the DISABLED state. +- [x] Preserve `OutboundHttpSettings` constructors as migration API, but remove its global + `@ConfigurationPropertiesScan` participation. +- [x] Keep provider definitions inert data; configuration alone must not create a transport. + +### Task 2: Add catalog/readiness registries and pure fail-closed activation resolution + +**Files:** +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java` +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java` +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java` +- Create: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java` +- Test: + `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java` + +- [x] Prove `DISABLED + bindings 0 + provider definitions 0` resolves to + `DISABLED_VERIFIED`, selected binding/card count 0. +- [x] Reject `DISABLED` with bindings or provider resources. +- [x] Reject `ACTIVE` with zero bindings. +- [x] For every binding, require an exact provider, provider destination, and registered operation + catalog for the same destination. +- [x] Derive the `httpclient-static-buffered` card from each current buffered classic profile. +- [x] Mark that card `NOT_IMPLEMENTED`; reject ACTIVE before any provider resource/factory exists. + +### Task 3: Move HTTP Spring activation to the composition root + +**Files:** +- Modify: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java` +- Modify: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java` +- Modify: + `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java` +- Create: + `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java` +- Modify: `src/app-bootstrap/src/main/resources/application.yml` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- Test: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java` + +- [x] Detach legacy HTTP infrastructure from component/configuration-properties scanning while + preserving direct constructors/factory methods used by forks and existing unit tests. +- [x] Register only canonical configuration, immutable registries, resolver, and inert descriptor + in the composition root. +- [x] Default application YAML to canonical `expected-state: DISABLED`, empty bindings, and empty + provider definitions; keep legacy migration keys out of both main and test application YAML. +- [x] Assert zero `OutboundHttpClient`, `RestClient`, `OutboundCallExecutor`, + `OutboundHttpShutdownGuard`, `OutboundHttpResilience`, `RetryRegistry`, and + `CircuitBreakerRegistry` beans/resources in the default context. +- [x] Assert contradictory/ACTIVE configurations fail startup before resource construction. +- [x] Load the real `application.yml` in composition tests and prove ACTIVE reaches the + `NOT_IMPLEMENTED` readiness card rather than a legacy conflict. + +### Task 4: Document exact readiness and verify + +**Files:** +- Modify: `src/adapter/outbound/httpclient/README.md` +- Modify: `src/adapter/outbound/httpclient/CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` +- Modify: + `docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md` + +- [x] Mark canonical zero-binding as implemented without marking HTTP R2 complete. +- [x] Keep HC5/provider resources/security/real-network qualification explicitly unimplemented. +- [x] Run focused tests: + +```bash +cd src +./gradlew :adapter:outbound:httpclient:check --rerun-tasks --console=plain +./gradlew :app-bootstrap:check --rerun-tasks --console=plain +./gradlew :sample-portfolio:test --rerun-tasks --console=plain +./gradlew verifyCleanArchitectureDependencies verifyConfigurationPropertiesProcessor \ + verifyEnvKeys verifyPublicPathSnapshot --console=plain +``` + +Do not edit unrelated notification, messaging, object-storage, JPA, MongoDB, GraphQL, gRPC, web, or +WebSocket files. diff --git a/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md b/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md index ea227cf..18cd748 100644 --- a/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md +++ b/docs/superpowers/plans/2026-07-28-httpclient-production-capability-foundation.md @@ -12,9 +12,10 @@ own feature-specific semantic ports. `adapter:outbound:httpclient` owns destinat immutable operation descriptors, relative target construction, status/retry/body semantics, and legacy provider fixes. The generic `OutboundHttpClient` remains a migration facade. -**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical binding -composition, exact readiness tuple registry, Apache HC5 pool, active cancellation, TLS/DNS/proxy, -auth, codec, and real-network qualification remain unimplemented. +**Scope boundary:** This applies Phase 0 and a bounded Phase 1 foundation. Canonical zero-binding +composition and active logical cancellation were implemented by later tracked plans. Exact +readiness tuple registry, Apache HC5 pool, TLS/DNS/proxy, auth, codec, and real-network +qualification remain unimplemented. --- @@ -24,9 +25,9 @@ auth, codec, and real-network qualification remain unimplemented. - Create: `src/application-core/src/main/java/dev/caskeleton/application/outbound/CallBudget.java` - Test: `src/application-core/src/test/java/dev/caskeleton/application/outbound/CallBudgetTest.java` -- [ ] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection. -- [ ] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types. -- [ ] Verify GREEN. +- [x] Write RED tests for expiry, remaining time, finite bounds, and parent/child intersection. +- [x] Implement without Spring, wall-clock timestamps, scheduler, or HTTP types. +- [x] Verify GREEN. ### Task 2: Add typed operation catalog and safe target construction @@ -41,11 +42,11 @@ auth, codec, and real-network qualification remain unimplemented. - Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalogTest.java` - Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/target/HttpTargetBuilderTest.java` -- [ ] Write RED tests for ID/uniqueness/cross-field operation invariants. -- [ ] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and +- [x] Write RED tests for ID/uniqueness/cross-field operation invariants. +- [x] Write RED tests rejecting absolute, scheme-relative, traversal, user-info, query/fragment, and multi-segment variables. -- [ ] Implement closed immutable descriptors and one-pass path-segment encoding. -- [ ] Verify GREEN. +- [x] Implement closed immutable descriptors and one-pass path-segment encoding. +- [x] Verify GREEN. ### Task 3: Correct characterized legacy provider safety defects @@ -54,11 +55,11 @@ auth, codec, and real-network qualification remain unimplemented. - Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java` - Test: `src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientSafetyRegressionTest.java` -- [ ] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting. -- [ ] Make streaming validate status before exposing the body and discard error bodies. -- [ ] Put circuit breaker around each physical attempt and retry around the attempt loop. -- [ ] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets. -- [ ] Verify focused regressions and the full legacy test suite. +- [x] Reproduce streaming 5xx body delivery and logical-call-only circuit-breaker counting. +- [x] Make streaming validate status before exposing the body and discard error bodies. +- [x] Put circuit breaker around each physical attempt and retry around the attempt loop. +- [x] Set JDK redirects to `NEVER` explicitly and validate legacy base URI/relative request targets. +- [x] Verify focused regressions and the full legacy test suite. ### Task 4: Record exact readiness and verify @@ -67,10 +68,10 @@ auth, codec, and real-network qualification remain unimplemented. - Modify: `src/adapter/outbound/httpclient/CLAUDE.md` - Modify: `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` -- [ ] Mark the implemented foundation and fixed legacy defects. -- [ ] Keep total deadline/cancellation, canonical zero-binding composition, Apache pool, fixed - egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented. -- [ ] Run: +- [x] Mark the implemented foundation and fixed legacy defects. +- [x] Track later total-deadline and canonical-zero-binding increments separately while keeping + Apache pool, fixed egress, TLS/auth, bounded decoded streaming, and R2 cards unimplemented. +- [x] Run: ```bash cd src diff --git a/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md b/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md new file mode 100644 index 0000000..556554d --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md @@ -0,0 +1,3203 @@ +# Messaging First R2 Polling Producer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) +> syntax for tracking. Behavior changes also require `superpowers:test-driven-development`; +> completion claims require `superpowers:verification-before-completion` and an independent +> `superpowers:requesting-code-review`. + +**Goal:** Build one production-reference Messaging path from a typed integration event through a +same-transaction PostgreSQL polling outbox to an acknowledgement-aware Spring Kafka producer, with +an authenticated disposition control and exact R2 evidence. + +**Architecture:** `application-core` owns provider-neutral event/publication/disposition semantics; +`adapter:outbound:messaging` owns deterministic JSON/schema compilation and Kafka; PostgreSQL +persistence owns event/delivery/audit rows and token/lease CAS; inbound web owns only operator HTTP +mapping; bootstrap composes the exact tuple, readiness and schedulers. The first path is polling-only +and keeps consumer, inbox, DLT, replay and CDC disabled. + +**Tech Stack:** Java 21, Spring Boot 4.0.0, Spring Kafka 4.0 through the Boot BOM, Jackson 3, +`com.networknt:json-schema-validator:3.0.2`, PostgreSQL, Flyway, JPA, Gradle, JUnit 5, AssertJ, +Testcontainers Kafka/PostgreSQL, Micrometer. + +--- + +- 작성일: 2026-07-28 +- 상태: 실행 계획 작성·독립 검토 완료, 모든 task 미착수 +- 설계 정본: + [Messaging Production Capability Deep Design](../specs/2026-07-28-messaging-production-capability-design.md) +- 구현 범위: P0–P4의 first R2 polling producer tuple +- 명시적 비범위: inbound Kafka consumer, inbox, retry topic, DLT/replay, Kafka EOS, + Debezium/Kafka Connect CDC, Avro/Protobuf/schema registry, alternate broker, multi-cluster +- 비교한 계획: + [Redis Foundation](2026-07-28-redis-production-capability-foundation.md), + [Redis Runtime](2026-07-28-redis-runtime-cache.md), + [HTTP Client Foundation](2026-07-28-httpclient-production-capability-foundation.md), + [HTTP Client Total Deadline](2026-07-28-httpclient-total-deadline.md), + [Fileserver Foundation](2026-07-28-fileserver-production-capability-foundation.md), + [Fileserver Durable Recovery](2026-07-28-fileserver-durable-recovery.md), + [Notification](2026-07-28-notification-production-capability.md) + +Repository commit policy는 모든 플랫폼에서 `human-only`다. 이 계획에는 `git add`, `git commit`, +`git amend`, `git push` 단계가 없다. 구현자는 작업 결과와 검증 증거만 전달하고 candidate +commit은 사람이 만든다. + +## 1. Exact selected tuple and non-guarantees + +첫 구현과 qualification 대상은 다음 tuple 하나다. + +```text +messaging-outbox-publish.v1 + + kafka-spring-acknowledged-idempotent.v1 + + postgresql-polling-outbox.v2 + + postgresql-per-record-jit-claim.v1 + + json-schema-envelope.v1 + + external-topic-validated.v1 + + kafka-sasl-ssl-scram-sha-512.v1 + + kafka-compression-none.v1 + + per-key-normal-path-sequence-detectable.v1 + + same-postgresql-transaction-resource.v1 + + authenticated-internal-web-disposition.v1 +``` + +이 계획이 완료돼도 다음은 주장하지 않는다. + +- broker와 PostgreSQL 사이 exactly-once; +- consumer effect의 deduplication 또는 inbox 보장; +- global FIFO, failure/rotation/requeue 뒤 strict FIFO; +- single-node Kafka test만으로 multi-broker RF/min ISR 내구성; +- CDC-ready, DLT-ready, replay-ready; +- local plaintext profile을 production security profile로 승격; +- `ACKNOWLEDGED`가 consumer 처리 또는 business effect 완료를 뜻함. + +## 2. Target flow and fixed decisions + +```text +feature mapper + -> IntegrationEventDraft + -> IntegrationEventEncoderPort + -> ValidatedIntegrationEvent(exact UTF-8 bytes + hashes) + -> TransactionPort.inWrite( + business state + + immutable outbox_event + + CURRENT/READY outbox_delivery + ) + +Outbox relay invocation + -> acquire one bounded local admission permit + -> Tx B: one-row JIT claim + token/DB-time lease + ATTEMPT_ADMITTED + -> no DB transaction: Kafka send + future ACK wait + -> Tx C: outcome observation + valid-lease/token CAS state transition + -> release permit + +late Kafka callback + -> bounded payload-free observation source + -> application drain + -> DB commit + -> source ACK + +authenticated internal endpoint + -> inbound DTO/principal mapping + -> ApplyOutboxDispositionUseCase + -> permission/policy + -> PostgreSQL CAS + immutable audit +``` + +고정 결정: + +1. `src/config/architecture/modules.json`이 leaf와 production project edge의 유일한 SSOT다. + first R2 production 구현에는 새 leaf나 project edge가 필요 없다. +2. `sample-portfolio -> adapter-outbound-messaging` edge는 standalone sample을 실제 ACTIVE + producer로 바꾸는 별도 승인 작업 전에는 추가하지 않는다. +3. application/domain/shared Java API에는 Kafka, Jackson, JSON validator, Spring, JPA 타입을 + 노출하지 않는다. +4. physical topic은 application contract가 아니라 outbound destination binding이다. +5. exact UTF-8 `BYTEA`가 wire authority다. retry에서 payload를 다시 직렬화하지 않는다. +6. `outbox_event`는 immutable event, `outbox_delivery`는 mutable delivery control이다. +7. claim/outcome/renew는 opaque token, owner, CURRENT generation, expected version, + `claim_until > database_now`를 모두 확인한다. +8. local admission을 확보한 뒤 한 record만 JIT claim한다. initial profile의 admitted record + upper bound는 1이다. +9. broker call은 DB transaction 밖에서 수행한다. +10. `ACKNOWLEDGED`, `ACKNOWLEDGED_MISMATCH`, `REJECTED`, `INDETERMINATE`는 exhaustive outcome이다. +11. acceptance certainty와 retry disposition은 독립 축이다. +12. deadline 뒤 late ACK는 기존 outcome/state를 뒤집지 않고 append-only observation만 제안한다. +13. operator requeue는 기존 row를 READY로 덮지 않고 이전 authority를 supersede한 뒤 새 + delivery generation을 만든다. +14. requeue generation deadline은 + `min(generation.created_at + maximumAutomaticPublicationAge, + event.created_at + sameEventRequeueHorizon)`이다. +15. P2는 additive schema/control-plane candidate일 뿐이다. `LEGACY_POLLING` authority는 P3의 + fenced cutover까지 유지한다. +16. live non-empty V3 database는 base template migration이 자동 backfill하지 않는다. 별도 + deployment migration design과 승인이 없으면 중단한다. +17. production ACTIVE는 SASL_SSL + SCRAM-SHA-512, external topic attestation, least-privilege + evidence가 없으면 실패한다. +18. disabled state는 contract/destination/client/AdminClient/thread/scheduler/network/secret + refresh가 모두 0이다. + +## 3. Scope boundary and owner leaves + +| 책임 | owner leaf | Gradle path | production edge 변경 | +| --- | --- | --- | --- | +| typed event, outcome, relay, late drain, disposition policy | `application-core` | `:application-core` | 없음 | +| generic envelope schema resource | `shared-contract` | `:shared-contract` | 없음 | +| JSON/schema/catalog/Kafka/provider lifecycle | `adapter-outbound-messaging` | `:adapter:outbound:messaging` | 외부 dependency만 추가 | +| event/delivery/journal/epoch/CAS | `adapter-outbound-persistence-jpa` | `:adapter:outbound:persistence-jpa` | 없음 | +| authenticated operator HTTP mapping | `adapter-inbound-web` | `:adapter:inbound:web` | 없음 | +| tuple composition/readiness/schedulers/real-service lane | `app-bootstrap` | `:app-bootstrap` | test dependency만 추가 | +| sample payload/schema/contribution fixture | `sample-portfolio` | `:sample-portfolio` | messaging edge 없음 | + +금지: + +- controller가 repository, JPA entity 또는 outbound adapter를 직접 사용; +- persistence mapper/query에 retry, disposition 또는 topic 정책을 넣음; +- messaging adapter가 sample, persistence 또는 inbound-web를 의존; +- bootstrap settings/configuration에 business event mapping이나 retry policy를 구현; +- `shared-contract`에 WorkLog schema 또는 provider setting을 넣음; +- 현재 dirty worktree의 Fileserver/Object Storage/Notification 변경을 되돌리거나 덮어씀. + +## 4. Evidence ladder and promotion rule + +| evidence | 허용되는 주장 | +| --- | --- | +| pure/application unit | provider-neutral contract와 state policy가 정의됨 | +| schema/catalog/codec unit/property | local deterministic document와 closed catalog가 정의됨 | +| adapter fake gateway | outcome mapping과 lifecycle protocol이 정의됨 | +| real PostgreSQL | same-store append, constraint, claim/CAS/audit protocol의 local evidence | +| single-node real Kafka | actual ACK metadata와 client/provider behavior evidence | +| TLS/SASL/ACL lane | exact security principal/profile evidence | +| multi-broker RF/min ISR lane | selected topology failure/recovery evidence | +| fault/capacity/rotation/cutover drill | exact tuple의 operational R2 evidence | + +낮은 row를 높은 row, 다른 broker version, cluster, topic, principal 또는 security profile로 +일반화하지 않는다. 모든 selected scenario가 fresh evidence artifact에 PASS일 때만 machine card를 +`release-eligible`로 바꾼다. 그 전에는 최대 `implemented-candidate`다. + +## 5. Execution rules + +1. 모든 checkbox는 구현 시작 시 `[ ]`다. +2. task 시작 전 `git status --short`, 현재 migration 목록, owner leaf의 가장 가까운 + `CLAUDE.md`, `modules.json` edge를 다시 확인한다. +3. behavior task는 RED test 작성 → 같은 focused command에서 예상 원인으로 실패 확인 → 최소 + 구현 → 같은 command GREEN 순서를 지킨다. +4. RED가 처음부터 통과하면 기존 coverage인지 잘못된 test인지 조사하고 assertion을 강화한다. +5. compilation drift, 외부 환경 또는 unrelated dirty change가 RED 원인이면 구현하지 말고 + 원인을 분리한다. +6. 한 shared worktree에서 여러 Gradle process를 동시에 실행하지 않는다. 이전에 같은 output + directory를 병렬 갱신해 compile collision이 발생했으므로 Gradle command는 한 invocation으로 + 묶거나 순차 실행한다. +7. 실제 service가 필요한 release task는 service/credential/image/no-test 문제를 SKIP/PASS로 + 바꾸지 않는다. local ordinary `test`와 release qualification task를 분리한다. +8. migration은 expand-first, forward-only다. 기존 `V3__outbox_event.sql`은 수정하지 않는다. +9. 새 provider와 v2 scheduler는 authority cutover 전까지 dark/disabled다. +10. P2에서 v2 claim/send/authority switch를 활성화하지 않는다. +11. event/payload/schema/hash/credential/raw header는 log, metric tag, evidence artifact에 넣지 + 않는다. +12. 각 Wave exit에서 설계 §0 ledger, card maturity, plan checkbox, LLM Wiki branch-note를 실제 + 증거에 맞춰 갱신한다. +13. plan surface 밖의 파일이나 타입이 필요하면 조용히 확장하지 않고 이 문서를 먼저 갱신한다. +14. 설계와 plan이 충돌하면 구현으로 타협하지 않고 상세 설계를 먼저 수정·재승인한다. + +## 6. Stop conditions + +다음 중 하나라도 확인되면 해당 task 또는 Wave를 중단한다. + +- application/domain에 framework, Kafka, JSON, persistence 타입을 넣어야만 진행 가능; +- module edge가 `modules.json`에 허용되지 않음; +- `V7`이 실행 시점에 이미 다른 migration으로 사용됐거나 다른 승인 계획이 먼저 구현됨. + 모든 Flyway location을 다시 스캔해 다음 global version으로 이 계획과 tests를 먼저 갱신한다; +- V3 legacy row가 live non-empty인데 empty/drained evidence나 별도 live migration 승인이 없음; +- business repository와 outbox append가 같은 transaction resource임을 증명할 수 없음; +- Kafka producer retry/timeout/effective setting을 finite하게 고정할 수 없음; +- adopted JSON Schema validator가 Draft 2020-12, offline registry, format assertion 또는 required + adversarial bound를 만족하지 못함; +- topic/RF/min ISR/ACL을 runtime와 provisioning evidence의 명시된 source로 attest할 수 없음; +- legacy relay와 v2 relay를 동시에 active하게 해야만 rollout 가능; +- active writer/relay/producer를 fence하지 않은 채 authority switch가 필요; +- DB에 INDETERMINATE/HOLD를 기록하지 못한 상태로 producer generation을 강제 전환해야 함; +- operator endpoint가 active unexpired claim을 무시하거나 raw status update를 해야 함; +- real Kafka/security/multi-broker evidence 없이 R2/production-ready 표현이 필요. + +## 7. Batch graph and checkpoints + +```text +Wave A / P0 + truth + machine registry skeleton + -> Wave B / P1 + application contract + schema + catalog + codec + -> Wave C / P2 + additive DB v2 + append + claim/CAS + policy + -> Wave D / P3 + Spring Kafka + endpoint + composition + cutover + -> Wave E / P4 + real-service/security/fault/release evidence +``` + +| Wave | exit claim | rollback posture | +| --- | --- | --- | +| A | current R0 truth와 planned cards가 정확함 | behavior 변화 없음 | +| B | local event contract/codec candidate | Kafka/outbox R2 아님 | +| C | polling v2 schema/control-plane candidate | LEGACY_POLLING 유지, v2 scheduler off | +| D | ACK-aware polling path/cutover candidate | pause admission, preserve DB schema/backlog/epoch | +| E | exact evidence가 통과한 tuple만 release-eligible | destructive schema downgrade 금지 | + +--- + +## Wave A — P0 truth freeze and execution scaffolding + +### Task 1: Freeze current R0 behavior and approved design truth + +**Owner:** documentation + existing application/messaging/persistence/bootstrap tests +**Depends on:** approved detailed design +**Behavior change:** none + +**Files — modify:** + +- `docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java` +- `src/adapter/outbound/messaging/README.md` + +**Files — create:** + +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java` + +- [ ] Capture current branch, `git status --short`, design digest, registry edges, dependency graph, + migrations and current test counts in the LLM Wiki branch-note. +- [ ] Add characterization assertions for: + broker blank → disabled sentinels; broker selected + missing sender → startup failure; broker ID + mismatch → failure; sender normal return → legacy `PUBLISHED`; sender exception → + `FAILED/DEAD`; ACK-to-mark failure → `IN_FLIGHT` and possible duplicate; same-transaction + append rollback; timestamp FIFO limitation. +- [ ] Keep tests explicitly named `legacy` or `characterization`; do not rename current void-return + success to broker ACK. +- [ ] Run the baseline sequentially: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*PublishPendingOutboxEventsUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*MessagingConfigTest' \ + --tests '*OutboxMessagePublishAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxAppendTransactionalContractTest' \ + --tests '*OutboxRowLifecycleContractTest' --console=plain + ``` + +- [ ] Expected GREEN: current behavior is reproducible without source behavior changes. +- [ ] Update §0 to `P0=CHARACTERIZED`, leaving P1–P4 `NOT_STARTED`. +- [ ] Acceptance: no “Kafka ACK”, “dedupe safe” or “R2” claim is introduced. + +**Rollback checkpoint:** characterization tests and truth documentation are independently reversible; +legacy code remains the executable baseline through the P3 cutover window. + +### Task 2: Add fail-closed Messaging card registries and verification task skeleton + +**Owner:** repository configuration + `app-bootstrap` contract tests +**Depends on:** Task 1 + +**Files — create:** + +- `src/config/messaging/readiness-cards.yaml` +- `src/config/messaging/profile-compatibility.yaml` +- `src/config/messaging/release-profile-assertions.yaml` +- `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java` + +**Files — modify:** + +- `src/build.gradle` +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/README.md` + +- [ ] Write a RED contract test that requires exactly the P0–P4 first tuple rows, closed maturity + values `not-implemented|implemented-candidate|release-eligible`, wildcard-free compatibility, + unique IDs, declared evidence tasks/scenarios/runbooks and no consumer/CDC/EOS/schema-registry + rows. +- [ ] Seed all first tuple rows with `maturity: not-implemented` and empty evidence fingerprint; do + not predeclare future extension-ledger names. +- [ ] Define one checked-in, payload-free build-evidence schema with required source/artifact digest, + producer task, scenario IDs/counts, command/timestamp, profile/catalog/schema/settings hashes, + failures, skips and unsupported claims. Every later local manifest validates against this + schema before release aggregation; a producer may add a stricter offline schema but may not + weaken these common fields. +- [ ] Define these task names in `src/build.gradle` without making them pass yet: + + ```text + verifyMessagingContracts + verifyMessagingJsonSchemaV1 + verifyMessagingPollingOutboxR2 + verifyMessagingKafkaProducerR2 + verifyMessagingSecurityR2 + verifyMessagingReleaseProfile + verifyMessagingTargetBindingPreflight + verifyMessagingTargetBinding + verifyMessagingDeploymentCutover + verifyMessagingCleanupTargetBinding + verifyMessagingFinalR2Profile + ``` + + Each task must fail on no matching tests. Release aggregation must reject missing, skipped, + stale, wrong-source or mismatched-profile evidence. +- [ ] Verify RED then GREEN for registry structure only: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingCapabilityRegistryContractTest' --console=plain + ``` + +- [ ] Verify the existing dependency boundary remains unchanged: + + ```bash + cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Acceptance: registry truth exists, every card is `not-implemented`, and no verification task + can falsely claim R2. + +**Rollback checkpoint:** registry/task scaffolding creates no runtime resources and may be removed +without data migration. + +--- + +## Wave B — P1 typed contract, schema, catalog and deterministic bytes + +### Task 3: Add framework-free integration-event contract and contribution SPI + +**Owner:** `application-core` (`:application-core`) +**Depends on:** Task 2 + +**Files — create under +`src/application-core/src/main/java/dev/caskeleton/application/messaging/`:** + +- `contract/IntegrationPayload.java` +- `contract/IntegrationEventContractContribution.java` +- `contract/ContractId.java` +- `contract/LogicalDestinationId.java` +- `contract/SchemaResourceId.java` +- `contract/Sha256.java` +- `contract/ContractDescriptor.java` +- `event/EventId.java` +- `event/AggregateIdentity.java` +- `event/AggregateOrder.java` +- `event/IntegrationEventDraft.java` +- `event/ValidatedIntegrationEvent.java` +- `event/IntegrationEventEncoderPort.java` + +**Files — create under +`src/application-core/src/test/java/dev/caskeleton/application/messaging/`:** + +- `contract/IntegrationEventContractContributionTest.java` +- `event/IntegrationEventDraftTest.java` +- `event/ValidatedIntegrationEventTest.java` + +**Files — modify:** + +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` + +- [ ] Write RED value tests for canonical ASCII event ID grammar, closed contract/destination IDs, + positive versions, nonblank canonical tenant scope, aggregate sequence/index bounds, + immutable/defensively-copied bytes and fixed SHA-256 length. +- [ ] Write RED SPI tests requiring exact final Java record payload type, canonical component order, + schema resource/hash and provider-neutral descriptor. Reject `Map`, raw JSON string/tree, + assignable-type discovery and Java class-name routing. +- [ ] Implement one-public-type-per-file framework-free records/interfaces. The boundary shape is: + + ```java + public interface IntegrationPayload {} + + public interface IntegrationEventContractContribution

{ + ContractId contractId(); + int payloadVersion(); + Class

exactPayloadRecordType(); + List canonicalRecordComponentOrder(); + SchemaResourceId payloadSchemaResource(); + Sha256 payloadSchemaHash(); + ContractDescriptor descriptor(); + } + + public interface IntegrationEventEncoderPort { + ValidatedIntegrationEvent encode(IntegrationEventDraft draft); + } + ``` + +- [ ] Keep physical topic, Kafka record metadata, JSON node, serializer, schema validator and + publication epoch out of these types. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests 'dev.caskeleton.application.messaging.*' --console=plain + ``` + +- [ ] Run application purity: + + ```bash + cd src && ./gradlew verifyApplicationCoreDependencyPurity \ + verifyOneTypePerFile --console=plain + ``` + +- [ ] Acceptance claim: framework-free semantic contract R1 only; no schema/Kafka/persistence R2. + +**Rollback checkpoint:** these are additive contracts; legacy `NewOutboxEvent` remains until the +validated append path is green. + +### Task 4: Check in the generic envelope schema and sample payload contract + +**Owner leaves:** `shared-contract` (`:shared-contract`), `sample-portfolio` +(`:sample-portfolio`) +**Depends on:** Task 3 + +**Files — create:** + +- `src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json` +- `src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.sha256` +- `src/shared-contract/src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java` +- `src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json` +- `src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java` +- `src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json` +- `src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java` + +**Files — modify:** + +- `src/shared-contract/README.md` +- `src/shared-contract/CLAUDE.md` +- `src/sample-portfolio/README.md` +- `src/sample-portfolio/CLAUDE.md` + +- [ ] Write RED resource tests requiring UTF-8, explicit Draft 2020-12 `$schema`, immutable absolute + `$id`, checked-in lowercase SHA-256, `unevaluatedProperties: false`, bounded strings/arrays, + required/null/missing policy and no HTTP/file remote `$ref`. +- [ ] Define envelope v1 with the exact fields frozen by design: + + ```json + { + "envelopeVersion": 1, + "eventId": "event-1", + "contractId": "portfolio.worklog.reserved", + "payloadVersion": 1, + "logicalDestination": "portfolio-domain-events", + "aggregate": { + "type": "worklog", + "id": "worklog-42", + "sequence": 17, + "eventIndex": 0 + }, + "occurredAt": "2026-07-28T05:10:30.123Z", + "correlationId": "corr-1", + "contentType": "application/json", + "payload": { + "workLogId": "worklog-42" + } + } + ``` + +- [ ] Keep the envelope business-free and keep the WorkLog payload schema only in sample. +- [ ] Make `WorkLogReservedPayload` a typed immutable record implementing `IntegrationPayload`; + contribution provides type/order/resource/hash only and no JSON mapper. +- [ ] Do not add `sample-portfolio -> adapter-outbound-messaging` to `modules.json` or Gradle. +- [ ] Verify RED then GREEN sequentially: + + ```bash + cd src && ./gradlew :shared-contract:test \ + --tests '*MessagingEnvelopeSchemaResourceTest' --console=plain + cd src && ./gradlew :sample-portfolio:test \ + --tests '*WorkLogReservedContractContributionTest' --console=plain + ``` + +- [ ] Acceptance claim: checked-in generic/sample contract artifacts exist; validator compatibility + is still unproven until Task 6. + +**Rollback checkpoint:** resources and sample contribution are additive; no production runtime +discovers or publishes them yet. + +### Task 5: Compile the closed contract, destination and exact capability binding + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Tasks 3–4 + +**Files — create under +`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/`:** + +- `contract/ContractCatalogCompiler.java` +- `contract/CompiledIntegrationEventContract.java` +- `contract/ContractCatalogDigest.java` +- `destination/DestinationBindingSettings.java` +- `destination/DestinationBindingCompiler.java` +- `destination/CompiledPublicationBinding.java` +- `destination/PartitionKeyV1.java` +- `config/MessagingCapabilityCardRegistry.java` +- `config/CompiledMessagingDescriptor.java` + +**Files — create under +`src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/`:** + +- `contract/ContractCatalogCompilerTest.java` +- `contract/ContractCatalogDigestTest.java` +- `destination/DestinationBindingCompilerTest.java` +- `destination/PartitionKeyV1Test.java` +- `config/MessagingCapabilityCardRegistryTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/README.md` +- `src/adapter/outbound/messaging/CLAUDE.md` + +- [ ] Write RED tests for duplicate contract/destination/schema IDs; missing binding; unknown card; + final-record exact type; component-order mismatch; code/deployment byte-bound intersection; + config attempting to relax ordering/schema/security; legacy + canonical conflict; unsupported + future card rejection. +- [ ] Add golden partition-key vectors using the design's domain-separated, length-prefixed SHA-256 + input. Assert exactly 64 lowercase hex ASCII characters and tenant-enabled/disabled canonical + non-null scope. +- [ ] Compile: + + ```text + contract descriptor + + destination descriptor + + producer/serialization/security/card descriptor + = immutable CompiledPublicationBinding + ``` + + Physical topic and bootstrap servers stay only in the compiled deployment binding. +- [ ] Compute stable catalog/settings/schema digests using sorted IDs and length-prefixed bytes; + never depend on `Map` iteration order or `toString()`. +- [ ] Empty catalog + DISABLED must compile to a zero-resource descriptor. ACTIVE + empty catalog + must fail before any client/thread is created. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*ContractCatalog*Test' \ + --tests '*DestinationBindingCompilerTest' \ + --tests '*PartitionKeyV1Test' \ + --tests '*MessagingCapabilityCardRegistryTest' --console=plain + ``` + +- [ ] Acceptance claim: closed local binding compiler R1; no wire bytes or Kafka client yet. + +**Rollback checkpoint:** compiler is not wired into `MessagingConfig`; legacy selection remains +authoritative. + +### Task 6: Implement the deterministic JSON Schema envelope encoder + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 5 + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoder.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeHashV1.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdversarialCorpusTest.java` +- `src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.schema.json` +- `src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.valid.json` +- `src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.invalid.json` + +**Files — modify:** + +- `src/adapter/outbound/messaging/build.gradle` +- `src/adapter/outbound/messaging/gradle.lockfile` +- `src/build.gradle` + +- [ ] Write RED tests for Draft 2020-12 meta-schema, checksum mismatch, duplicate `$id`, unknown + dialect/vocabulary, remote/unmapped `$ref`, cycles beyond the supported depth, pathological + regex corpus, format assertion, valid/invalid envelope and payload, required/null/missing, + unknown property and unsupported payload version. +- [ ] Write RED parser/admission tests for duplicate JSON key, malformed UTF-8, unpaired surrogate, + trailing garbage, depth/string/array/object/number bounds, non-finite number, exact UTF-8 + value/key/header bytes and deterministic field/scalar order. +- [ ] Add: + + ```groovy + implementation 'org.springframework.boot:spring-boot-starter-json' + implementation('com.networknt:json-schema-validator:3.0.2') { + exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml' + } + ``` + + Keep Jackson/schema runtime in the messaging leaf. Regenerate only affected dependency locks + and review the resolved Jackson 3 graph, license and vulnerability report. +- [ ] Configure NetworkNT Draft 2020-12 with format assertions enabled and an exact classpath + resource map. After startup compilation, network/file schema resolution is impossible. +- [ ] Make the writer consume only exact registered final record types. Disable polymorphic typing, + feature-provided serializers, unknown properties and reflective assignable-type search. +- [ ] Compute: + + ```text + SHA-256( + UTF8("ca-skeleton.messaging.envelope.v1") || 0x00 + || u32be(len(exactEnvelopeBytes)) + || exactEnvelopeBytes + ) + ``` + + and return defensive copies in `ValidatedIntegrationEvent`. +- [ ] Add validator compatibility evidence using the adopted JSON Schema Test Suite/Bowtie corpus; + custom contract compatibility still requires repository golden vectors. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*LocalJsonSchemaRegistryTest' \ + --tests '*JsonSchemaIntegrationEventEncoderTest' \ + --tests '*EnvelopeAdversarialCorpusTest' --console=plain + cd src && ./gradlew verifyMessagingJsonSchemaV1 \ + verifyDependencyLocks --console=plain + ``` + +- [ ] On GREEN, `verifyMessagingJsonSchemaV1` validates and writes this exact payload-free candidate + manifest: + + ```text + src/build/messaging-evidence/contracts-schema/manifest.json + ``` + + It conforms to `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` and binds + the human/CI-supplied source/artifact digest, schema/catalog hashes, dependency-lock digest, + exact scenario IDs/counts, command/timestamp, failed=0, skipped=0 and unsupported claims. + Missing digest input fails the manifest-producing lane; an ordinary focused unit test may + still run without claiming release evidence. +- [ ] Update JSON/schema cards to `implemented-candidate` only after the exact tests and locks pass. +- [ ] Acceptance claim: deterministic local wire contract candidate; Kafka and durable outbox R2 are + still unimplemented. + +**Rollback checkpoint:** encoder/catalog stays unwired from production append; removing it does not +change legacy rows. + +### Wave B exit checkpoint + +- [ ] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :shared-contract:check \ + :adapter:outbound:messaging:check \ + :sample-portfolio:check \ + verifyMessagingContracts \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Confirm no production leaf imports `dev.caskeleton.sample`. +- [ ] Update the design ledger to `P1=IMPLEMENTED_CANDIDATE` only if all Wave B evidence is GREEN. +- [ ] Update the LLM Wiki branch-note; record whether a new derived raw document exists or explicitly + record “없음”. + +--- + +## Wave C — P2 immutable event, polling delivery and operator policy + +### Task 7: Add the forward-only PostgreSQL outbox v2 schema + +**Owner:** `adapter:outbound:persistence-jpa` (`:adapter:outbound:persistence-jpa`) +**Depends on:** Wave B +**Activation:** schema/control-plane only; `LEGACY_POLLING` remains ACTIVE + +**Candidate migration file:** + +- `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__messaging_outbox_v2.sql` + +`V7` is the current candidate because the sample Flyway location already contains +`V6__poster.sql`. The Notification plan also uses `V7`/`V8` as candidates; plan text is not a +simultaneous Flyway reservation. Before implementation, scan every runtime Flyway location and all +implemented or actively executing plans. The first implementation claims the next global version; +the later plan must reserve the following version and update every path/test before writing SQL. +Messaging and Notification persistence migrations must not execute concurrently with unresolved +version ownership. + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDeliveryId.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDeliveryEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDeliveryAttemptObservationEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDispositionAuditEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxPublicationEpochEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxAuthorityCutoverEvidenceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAttemptObservationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDispositionAuditJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPublicationEpochJpaRepository.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2MigrationContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` + +- [ ] Before editing, classify V3 row count/state/data and assert the base card accepts only a fresh + or verified empty/drained legacy table. Any live non-empty database fails this task pending a + separate deployment-specific migration plan. +- [ ] Write a RED real-PostgreSQL migration test. Expected failure: v2 columns/tables/constraints do + not exist. +- [ ] Keep `V3__outbox_event.sql` byte-for-byte unchanged. Add immutable metadata columns + additively while retaining legacy columns for compatibility. +- [ ] Widen `event_id VARCHAR(64)` to `VARCHAR(96)` in the forward migration; this is compatible + with old writers' shorter grammar. Keep the other V3 NOT NULL columns through the rollback + window and choose one explicit compatibility projection for every canonical insert: + + ```text + event_type = contract_id legacy alias + payload = exact UTF-8 envelope bytes decoded as text + status = PENDING compatibility sentinel + attempt_count = 0 + next_attempt_at = occurred_at + idempotency_key = event_id + ``` + + These columns are not authority after `POLLING_V2`. Epoch predicates prevent every legacy + claim/mutation/reaper from observing canonical rows, and a post-cutover immutable guard + prevents them from drifting. Do not relax NOT NULL/defaults or leave canonical inserts + unspecified. +- [ ] Create: + + ```text + outbox_delivery + outbox_delivery_attempt_observation + outbox_disposition_audit + outbox_publication_epoch + outbox_authority_cutover_evidence + outbox_write_admission + outbox_runtime_node_lease + ``` + + with event/generation primary keys, one-CURRENT partial unique constraint, delivery FK, + authority/state CHECKs, DB timestamps, row version, immutable automatic deadline and an + expiring one-shot cutover evidence identity/digest. A cutover attempt has the closed durable + state machine `CUTOVER_PENDING -> FINALIZING_V2 -> CONSUMED_V2` or + `CUTOVER_PENDING -> RECOVERING_LEGACY -> RECOVERED_LEGACY`; the two branches are mutually + exclusive CAS transitions. `RECOVERING_LEGACY` also stores an opaque recovery operation ID, + owner/lease deadline and recovery evidence digest. Lease expiry permits recovery-only + takeover and never resets the attempt to `CUTOVER_PENDING`. Give every attempt the constant + database authority scope `OUTBOX_PUBLICATION`, ACTIVE legacy epoch ID, frozen fence generation + and target-binding digest. A partial unique constraint permits exactly one nonterminal + (`CUTOVER_PENDING`, `FINALIZING_V2`, `RECOVERING_LEGACY`) attempt in that authority scope. + Attempt creation, finalization and recovery lock the write-admission singleton first and the + ACTIVE epoch second, then validate the exact frozen generation/target binding before touching + the attempt. The write admission singleton starts OPEN at generation 1; runtime node leases + are bounded and bind + node/source/artifact/fence-protocol identity without payload or credentials. +- [ ] Add event ID, partition-key, SHA-256, tenant-scope, order uniqueness and exact `BYTEA` + constraints. Protect immutable event columns with a post-cutover guard that is dormant during + compatibility migration and enabled only by the fenced P3 cutover. +- [ ] Seed exactly one ACTIVE `LEGACY_POLLING` epoch/generation for fresh/empty base template. Do not + activate `POLLING_V2`, create v2 delivery for live legacy rows or claim/send from v2. +- [ ] RED/GREEN cases: + fresh V1–V7; V3-empty upgrade; non-empty preflight rejection; duplicate current generation; + nullable tenant attack; invalid hash/key/event ID; mutable event update after guard; FK/audit + retention; publication epoch uniqueness; duplicate nonterminal authority attempt under + concurrent insert; illegal finalization/recovery state transition; 65–96 character event ID; + old-writer short ID; canonical compatibility projection satisfying every retained V3 NOT NULL + constraint. +- [ ] Verify: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxV2MigrationContractTest' --console=plain + ``` + +- [ ] Acceptance claim: additive polling v2 schema candidate only; legacy relay authority unchanged. + +**Rollback checkpoint:** rollback disables new code and keeps additive schema/backlog. Never +destructively downgrade the database. + +### Task 8: Append validated event and initial delivery in the business transaction + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa`, `app-bootstrap` test +fixture +**Depends on:** Task 7 + +**Files — create:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAppendAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAppendAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2AppendTransactionalContractTest.java` + +**Files — modify:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java` + +- [ ] Write RED application tests making `OutboxAppendPort.append(ValidatedIntegrationEvent)` the + only canonical method. Move raw `NewOutboxEvent` append to a separately named/deprecated + `LegacyOutboxAppendPort`; never overload or silently reinterpret raw payload as v1. +- [ ] Split the current combined store/append implementation: `OutboxStoreAdapter` remains only the + legacy relay store during the observation window, while a separately named + `LegacyOutboxAppendAdapter` implements only `LegacyOutboxAppendPort`. It has no component + annotation and bootstrap may compose it only for the explicit sample/R0 compatibility graph. +- [ ] Write RED real-PostgreSQL tests proving business state + event + current delivery commit or + rollback together; encoder/schema failure rolls back business state; exact `BYTEA` and hash + round-trip. Cross-resource ACTIVE startup rejection belongs to Task 19 after both leaf + descriptors exist. +- [ ] In the persistence adapter, lock/read the ACTIVE publication epoch inside the caller-owned + transaction, attach DB-authoritative `created_at`, `publication_epoch`, + `dispatch_authority`, `transaction_resource_id`, and insert initial delivery only when + authority is `POLLING_V2`. +- [ ] For an initial current delivery, compute and persist in that same DB transaction: + + ```text + automaticAttemptDeadline = + min(eventDbCreatedAt + maximumAutomaticPublicationAge, + eventDbCreatedAt + contract.sameEventRequeueHorizon) + ``` + + The value is immutable and profile reload never moves an existing generation's deadline. +- [ ] During compatibility `LEGACY_POLLING`, write both legacy required columns and validated v2 + metadata in the same transaction but do not create/send a v2 current delivery. +- [ ] Make the legacy claim read model distinguish true v0 rows from rows carrying canonical v1 + metadata without exposing a physical topic in application. For a canonical row, + `OutboxMessagePublishAdapter` resolves the stored logical destination through the closed + compiled binding and sends the stored partition-key bytes plus immutable `envelope_bytes` + byte-for-byte. It must not invoke `OutboxEnvelopeJson` or reinterpret the retained V3 + `payload` projection. Only a true v0 row may use the old wrapper/event-type route. +- [ ] Add golden cases for canonical append under `LEGACY_POLLING` → legacy claim → exact compiled + destination/key/envelope bytes → legacy `PUBLISHED`. This remains + `LEGACY_RECORDED_UNVERIFIED` at cutover and is never automatically resent by v2. Test v0 and + canonical branches independently; mixed/missing metadata fails closed. +- [ ] Add a post-cutover canonical append fixture proving the retained V3 NOT NULL compatibility + projection, immutable event + CURRENT/READY delivery and deadline all commit together while + the epoch-fenced legacy claim/reaper sees the row count as 0. +- [ ] Use an app-bootstrap test-source typed contribution/draft to prove the canonical append path. + Do not inject the messaging encoder into `sample-portfolio` or add a project edge in this + task. Change the sample use case dependency explicitly to `LegacyOutboxAppendPort`; the + existing sample mapper remains a visibly R0, non-active compatibility fixture until the + separate standalone-sample activation plan. +- [ ] Remove component auto-discovery from the legacy append/store adapter. Bootstrap may compose it + only for an exact `LEGACY_POLLING` compatibility graph; canonical `POLLING_V2` must have + `LegacyOutboxAppendPort` bean count 0. Update every exact existing legacy/sample fixture listed + above in the same task so changing `OutboxAppendPort` cannot leave compile-only hidden users. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*Outbox*' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxAppendAdapterTest' \ + --tests '*LegacyOutboxAppendAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxV2AppendTransactionalContractTest' --console=plain + cd src && ./gradlew :sample-portfolio:test \ + --tests '*CreateWorkLogOutboxTest' --console=plain + ``` + +- [ ] Acceptance claim: validated same-transaction append candidate; v2 relay remains disabled. + +**Rollback checkpoint:** keep compatibility writes while rolling back the new relay. Do not generate +a second event ID or dual-write outside the transaction. + +### Task 9: Define exhaustive publication outcomes and one-record relay policy + +**Owner:** `application-core` (`:application-core`) +**Depends on:** Task 8 + +**Files — create under +`src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/`:** + +- `PublicationOutcome.java` +- `PublicationReceipt.java` +- `PublicationFailure.java` +- `AcceptanceCertainty.java` +- `RetryDisposition.java` +- `PublicationFailureStage.java` +- `PublicationFailureClass.java` +- `PublicationAttemptId.java` +- `PublicationAdmission.java` +- `PublicationAdmissionPort.java` +- `AcknowledgedPublicationPort.java` + +**Files — create under +`src/application-core/src/main/java/dev/caskeleton/application/outbox/`:** + +- `OutboxDelivery.java` +- `OutboxDeliveryState.java` +- `DeliveryAuthorityStatus.java` +- `ClaimToken.java` +- `ClaimedOutboxDelivery.java` +- `OutboxDeliveryStorePort.java` +- `PublishNextOutboxDeliveryCommand.java` +- `PublishNextOutboxDeliveryResult.java` +- `PublishNextOutboxDeliveryUseCase.java` + +**Files — create under +`src/application-core/src/test/java/dev/caskeleton/application/`:** + +- `messaging/publication/PublicationOutcomeTest.java` +- `outbox/PublishNextOutboxDeliveryUseCaseTest.java` +- `outbox/OutboxDeliveryStateTest.java` + +**Files — modify or retain as legacy until Task 26:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxBackoffPolicy.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java` + +- [ ] Write RED tests for the sealed outcome shape: + + ```java + public sealed interface PublicationOutcome { + record Acknowledged(PublicationReceipt receipt) implements PublicationOutcome {} + record AcknowledgedMismatch(PublicationReceipt receipt) implements PublicationOutcome {} + record Rejected(PublicationFailure failure) implements PublicationOutcome {} + record Indeterminate(PublicationFailure failure) implements PublicationOutcome {} + } + ``` + + Receipt/failure contains bounded provider-neutral values only; no Kafka SDK type or raw + exception/message. +- [ ] Test certainty and retry as independent axes. Ambiguous/post-admission/timeout/unknown maps to + `INDETERMINATE`; `REJECTED` requires definite non-acceptance. +- [ ] Write relay RED tests for this exact sequence: + + ```text + acquire bounded admission + -> Tx B claim exactly one row + ATTEMPT_ADMITTED + -> publish outside DB transaction + -> Tx C outcome observation + token/valid-lease CAS transition + -> release admission + ``` + +- [ ] Cover: + no admission → claim 0; no eligible row → release permit; ACK → `DELIVERY_RECORDED`; mismatch + → `HOLD`; definite transient rejection → `RETRY_WAIT`; permanent/budget exhaustion → + `EXHAUSTED`; indeterminate → duplicate-aware retry or HOLD according to remaining finite + budget; transition failure propagates; diagnostic reporter failure cannot change persisted + state. +- [ ] Enforce one command invocation/one record. A scheduler may invoke it again; the use case must + not loop and open per-row `REQUIRES_NEW` transactions. +- [ ] Replace attempt-only backoff with a descriptor that includes maximum attempts, immutable + automatic deadline, bounded delay/jitter and same-event horizon. Do not start age at + `first_attempt_at`. +- [ ] Keep legacy void port/use case explicitly deprecated and separately wired until Task 26; + canonical code must not adapt exception-only success into `Acknowledged`. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*PublicationOutcomeTest' \ + --tests '*PublishNextOutboxDeliveryUseCaseTest' \ + --tests '*OutboxDeliveryStateTest' --console=plain + ``` + +- [ ] Acceptance claim: application polling/outcome policy candidate; provider and DB CAS remain + adapter work. + +**Rollback checkpoint:** canonical use case remains unwired. Legacy relay continues to serve +`LEGACY_POLLING`. + +### Task 10: Implement per-record JIT claim, valid-lease CAS and attempt journal + +**Owner:** `adapter:outbound:persistence-jpa` (`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 9 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxDeliveryClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryStoreAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxDeliveryClaimRepositoryTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2ClaimCasContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2MultiWorkerContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java` + +- [ ] Write unit RED tests that the adapter maps application values without adding retry/topic + policy and requires affected-row count exactly 1 for every CAS. +- [ ] Write real-PostgreSQL RED tests for: + two workers claim disjoint rows; same aggregate total order uses sequence/index rather than + timestamp; different aggregates progress; hot aggregate does not starve all others; expired + claim reclaim; same-token renew; stale token/owner/generation/version rejection; expired but + not yet reclaimed owner cannot record ACK/failure. +- [ ] The worker-owned mutation predicate must include: + + ```sql + WHERE event_id = :event_id + AND delivery_generation = :generation + AND authority_status = 'CURRENT' + AND state = 'CLAIMED' + AND claim_token = :token + AND claim_owner = :owner + AND claim_until > CURRENT_TIMESTAMP + AND row_version = :expected_row_version + ``` + +- [ ] Claim eligibility is CURRENT `READY`, due `RETRY_WAIT` or expired `CLAIMED`, subject to + ordering-head eligibility. `EXHAUSTED`, `HOLD`, `LEGACY_RECORDED_UNVERIFIED` never release the + next ordered event. +- [ ] Tx B atomically updates claim count/token/owner/DB-time lease/publication attempt count and + inserts `ATTEMPT_ADMITTED`. Raw claim token is never copied; journal stores a domain-separated + digest. +- [ ] Before `ATTEMPT_ADMITTED`, use DB time to verify both the automatic deadline and a full + application-attempt/Tx-C safety window remain. If `database_now >= deadline` or the full + window does not fit, perform a fenced `EXHAUSTED` transition without admission/send. +- [ ] When reclaiming an expired `CLAIMED` row whose previous `publicationAttemptId` has + `ATTEMPT_ADMITTED` but no outcome, append exactly one idempotent + `OUTCOME_OBSERVED(INDETERMINATE)` for that old attempt before replacing the token and admitting + the new attempt. Never fabricate a definite rejection or erase the prior attempt. +- [ ] Tx C atomically inserts `OUTCOME_OBSERVED` and performs ACK/retry/exhaust/hold CAS. Provider + metadata is a bounded opaque reference. +- [ ] Use DB time for eligibility, lease, created-at and retry due. Before send admission, ensure + remaining lease exceeds the full attempt + DB transition + safety budget. +- [ ] Verify RED then GREEN sequentially: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxDeliveryStoreAdapterTest' \ + --tests '*PostgreSqlOutboxDeliveryClaimRepositoryTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxV2ClaimCasContractTest' \ + --tests '*OutboxV2MultiWorkerContractTest' --console=plain + ``` + +- [ ] Acceptance claim: real-PostgreSQL JIT claim/CAS protocol candidate; no Kafka send or authority + cutover. + +**Rollback checkpoint:** leave v2 scheduler off and `LEGACY_POLLING` active. Claimed test rows are +disposable; production rollback preserves all event/delivery rows. + +### Task 11: Persist late publication observations with DB-commit-before-source-ACK + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa` +**Depends on:** Task 10 + +**Files — create in `application-core`:** + +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/ObservationId.java` +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/LatePublicationObservation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/LatePublicationObservationSourcePort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAttemptObservationPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsUseCaseTest.java` + +**Files — create in `adapter:outbound:persistence-jpa`:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAttemptObservationAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAttemptObservationAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLateObservationContractTest.java` + +- [ ] Write application RED tests for: + + ```text + poll/lease bounded batch + -> tx.inNew(idempotent DB append) returns after commit + -> acknowledgePersisted + ``` + + DB append/commit failure calls `releaseForRetry`; source ACK never runs in a transaction + callback. +- [ ] Use observation identity + `(eventId, deliveryGeneration, publicationAttemptId, LATE_ACK_OBSERVED)` and a DB unique + constraint/`ON CONFLICT DO NOTHING`. +- [ ] Test DB commit → process crash before source ACK by redelivering the same observation; exactly + one journal fact remains. +- [ ] Test late observation never changes `DELIVERY_RECORDED`, `RETRY_WAIT`, `EXHAUSTED`, `HOLD` or + current generation. It is diagnostic, not correctness authority. +- [ ] Test empty poll, bounded maximum, poison item release, source ACK failure and commit failure. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*RecordLatePublicationObservationsUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxAttemptObservationAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxLateObservationContractTest' --console=plain + ``` + +- [ ] Acceptance claim: durable idempotent late-observation drain boundary; callback capture is + still bounded-loss and no messaging queue exists until Task 16. + +**Rollback checkpoint:** disabling the drain loses only bounded diagnostics, never changes delivery +authority. Alert/readiness must expose the degradation. + +### Task 12: Implement audited application disposition policy and atomic persistence transitions + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa` +**Depends on:** Tasks 10–11 + +**Files — create in `application-core`:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxDisposition.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxDispositionResultCodec.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxDispositionPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionUseCaseTest.java` + +**Files — create in `adapter:outbound:persistence-jpa`:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDispositionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDispositionAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxDispositionTransactionalContractTest.java` + +- [ ] Write application RED tests with `@RequiresPermission("outbox:disposition")` and + `@UseCaseCapability(idempotency = Idempotency.KEYED, ...)`. + `SKIP_WITH_GAP` and `COMPENSATE` additionally call `AuthorizationPort` for + `outbox:disposition:destructive`. +- [ ] Command requires: + + ```text + eventId + expectedDeliveryGeneration + expectedRowVersion + disposition + bounded reason + incident/change reference + IdempotencyContext(scope + request fingerprint + bounded TTL) + operator principal + destructive approval reference when required + compensation event reference for COMPENSATE + ``` + +- [ ] Inject the existing application-owned `IdempotencyExecutor` into + `ApplyOutboxDispositionUseCase`. Execute authorization/policy/CAS exactly once under the + command's `IdempotencyContext`, using a framework-free deterministic + `OutboxDispositionResultCodec`. Replay returns the stored application result; same key with a + different request fingerprint raises the existing mismatch exception. Controller and + persistence adapter must not implement their own idempotency state machine. +- [ ] Application tests cover first execution, completed replay, in-flight conflict, request + mismatch, action failure/discard and bounded TTL. Persistence integration reuses the existing + `IdempotencyStorePort` adapter to prove atomic claim/complete; `outbox_disposition_audit` + remains the immutable business/operation audit rather than a second idempotency registry. +- [ ] Validate allowed source state, ordering impact, active-unexpired-claim absence and finite + same-event requeue horizon in application policy for early feedback. This precheck is not the + concurrency fence. +- [ ] In the persistence transaction, lock the CURRENT delivery row and atomically re-evaluate + expected generation/state/row version plus `NOT (state='CLAIMED' AND + claim_until > database_now)` before audit/mutation. Add a race test that inserts a worker + claim after application precheck but before the locked mutation; operator CAS must fail + without partial audit/handoff. +- [ ] Write real-PostgreSQL RED/GREEN for: + stale generation/version; live claim; idempotency replay/mismatch; concurrent requeue; one + CURRENT constraint; partial handoff rollback; old generation never claimable again; immutable + audit. +- [ ] REQUEUE transaction locks current row, inserts audit, marks old authority `SUPERSEDED`, sets + `superseded_by_generation`, and inserts generation + 1 `CURRENT/READY` with: + + ```text + automaticAttemptDeadline = + min(newDeliveryDbCreatedAt + maximumAutomaticPublicationAge, + eventDbCreatedAt + sameEventRequeueHorizon) + ``` + +- [ ] HOLD/SKIP/COMPENSATE/legacy accept use expected generation/state/row version and do not mimic + the worker token predicate. `COMPENSATED` requires an already-created immutable compensating + event reference in the same transaction. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*ApplyOutboxDispositionUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxDispositionAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxDispositionTransactionalContractTest' --console=plain + ``` + +- [ ] Acceptance claim: provider-neutral authenticated disposition policy and DB protocol candidate; + no HTTP surface yet. + +**Rollback checkpoint:** operator surface is not exposed. Data/audit rows are forward-only and must +not be rewritten by raw SQL. + +### Task 13: Prove P2 compatibility fence and no-dual-authority state + +**Owner:** persistence/bootstrap integration +**Depends on:** Tasks 7–12 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublicationEpochContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyV2CompatibilityContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyCanonicalWireCompatibilityContractTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxV2RetentionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxV2RetentionAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2RetentionContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java` + +- [ ] Write the epoch, canonical-wire compatibility and retention tests first, then run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxPublicationEpochContractTest' \ + --tests '*OutboxLegacyV2CompatibilityContractTest' \ + --tests '*OutboxLegacyCanonicalWireCompatibilityContractTest' \ + --tests '*OutboxV2RetentionContractTest' --console=plain + ``` + + Expected non-zero: the legacy claim/reaper lacks an epoch fence and v2 retention protocol is + absent. A compile failure unrelated to those missing contracts is not an accepted RED. +- [ ] RED test that the compatibility legacy writer fills v2 immutable metadata in the same + transaction while the legacy relay can claim only the exact ACTIVE `LEGACY_POLLING` + epoch/generation. +- [ ] Add a legacy mutation/claim fence predicate tied to the ACTIVE publication epoch. Old + pre-fence binaries are explicitly incompatible and must be drained to zero before P3. +- [ ] Prove `POLLING_V2` claim is rejected while `LEGACY_POLLING` is active and legacy claim is + rejected after the epoch changes. +- [ ] Prove no row can be claimed/sent by both paths; publication epoch lock and expected generation + are mandatory. +- [ ] Prove the compatibility publisher sends a canonical metadata row exactly once through the + legacy authority using the compiled destination, stored key and byte-identical v1 envelope; + v0 rows still use the old wrapper. Nested envelope, event-type-as-topic for canonical rows, + mixed metadata, and automatic resend of legacy `PUBLISHED` after cutover all fail. +- [ ] Bind the legacy reaper to the exact ACTIVE `LEGACY_POLLING` epoch and stop/drain it before + cutover. Implement v2 retention separately: delete only when the unique CURRENT generation is + resolved as `DELIVERY_RECORDED` or audited `SKIPPED/COMPENSATED/ + LEGACY_ACCEPTED_UNVERIFIED`, with no claim/requeue, unresolved observation, legal/operator + hold, audit-retention or replay-horizon obligation. +- [ ] Real PostgreSQL retention cases cover reaper-vs-claim/requeue, delivery/audit FK and + no-silent-cascade, superseded generations, `EXHAUSTED`, `HOLD`, + `LEGACY_RECORDED_UNVERIFIED`, unresolved late observation and epoch mismatch. +- [ ] Do not perform legacy row reconciliation, v2 delivery creation or authority switch in this + task. +- [ ] Re-run the same focused command GREEN; all three exact tests must pass with no skip. Then run + the candidate gate: + + ```bash + cd src && ./gradlew verifyMessagingPollingOutboxR2 --console=plain + ``` + + At P2, `verifyMessagingPollingOutboxR2` may report `implemented-candidate`; it must not emit a + release-eligible claim. On GREEN it validates and writes the exact candidate manifest: + + ```text + src/app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json + ``` + + The manifest conforms to + `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` and binds the supplied + source/artifact digest, migration/schema/card/profile hashes, exact PostgreSQL scenario + IDs/counts, commands/timestamps, failed=0, skipped=0 and unsupported Kafka/security claims. +- [ ] Update P2 card rows to `implemented-candidate` only if real PostgreSQL cases pass. + +**Rollback checkpoint:** keep `LEGACY_POLLING` ACTIVE and canonical v2 scheduler off. If the fence +cannot be deployed to all nodes, do not proceed to Wave D. + +### Wave C exit checkpoint + +- [ ] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + :app-bootstrap:test \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Confirm broker/client/network resources are still 0 and v2 authority has not switched. +- [ ] Update the design ledger to `P2=IMPLEMENTED_CANDIDATE` only from actual tests. +- [ ] Update the LLM Wiki branch-note and derived-document decision. + +--- + +## Wave D — P3 ACK-aware Spring Kafka, operator endpoint and reference-path cutover + +### Task 14: Bind and compile finite canonical Messaging settings + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Wave C + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingExpectedState.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingR2Settings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingSettingsCompiler.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/CompiledKafkaProducerSettings.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingSettingsCompilerTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingDisabledResourceContractTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerSettingsTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java` + +- [ ] Write RED binding/compiler tests for exact `DISABLED|ACTIVE` expected state and the canonical + fields in design §21.1. Raw `Map` Kafka overrides are forbidden. +- [ ] Freeze the first effective profile: + + ```text + acks=all + enable.idempotence=true + retries=MAX/effectively-unbounded under delivery.timeout.ms + max.in.flight.requests.per.connection<=5 + compression.type=none + partitioner.ignore.keys=false + finite request/delivery/max.block/linger/batch/buffer/request bounds + maximumAdmittedRecords=1 + ``` + +- [ ] Validate: + + ```text + deliveryTimeout >= requestTimeout + linger + applicationAttemptBudget >= + admissionWait + maxBlock + deliveryTimeout + callback/transitionReserve + claimLease > + applicationAttemptBudget + dbTransitionReserve + schedulingSafetyMargin + ``` + +- [ ] Reject ACTIVE + unknown/missing card/provider/bootstrap/destination/security; plaintext in + production; literal credentials; contract bytes over any bound; ordering + null key; + transaction resource mismatch; legacy + canonical keys; active durable contract + disabled + dispatch. +- [ ] DISABLED must instantiate no schema compiler with active contracts, producer factory, + template, AdminClient, semaphore, observation queue, scheduler, secret refresh or network + connection. +- [ ] Legacy keys are parsed only into an R0 descriptor and conflict with canonical keys. Do not map + `broker=kafka` to `kafka-spring` or `relay-enabled=true` to polling v2. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*MessagingSettingsCompilerTest' \ + --tests '*MessagingDisabledResourceContractTest' \ + --tests '*KafkaProducerSettingsTest' --console=plain + ``` + +- [ ] Acceptance claim: finite static descriptor candidate; no Kafka client created yet. + +**Rollback checkpoint:** canonical activation remains disabled; legacy settings continue only in R0 +mode. + +### Task 15: Add explicit Spring Kafka producer factory and ACK-aware gateway + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 14 + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerFactoryConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaPublishGateway.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGateway.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaPublicationFailureClassifier.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/publication/AckAwareOutboxPublicationAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerFactoryConfigTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGatewayTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaPublicationFailureClassifierTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/publication/AckAwareOutboxPublicationAdapterTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/build.gradle` +- `src/adapter/outbound/messaging/gradle.lockfile` + +- [ ] Add `implementation 'org.springframework.kafka:spring-kafka'`; accept the Spring Boot 4.0.0 BOM + version unless a separately reviewed compatibility override is necessary. Regenerate and + review the messaging lockfile. +- [ ] RED test exact producer properties and `DefaultKafkaProducerFactory` / + `KafkaTemplate`. Use byte serializers; no JSON serialization in Kafka + callbacks. +- [ ] RED gateway tests for: + + ```text + future success + metadata + expected topic -> ACKNOWLEDGED + future success + metadata + wrong topic -> ACKNOWLEDGED_MISMATCH + definite pre-admission/local rejection -> REJECTED + ambiguous/post-admission/deadline/unknown -> INDETERMINATE + ``` + +- [ ] Build `ProducerRecord` only from compiled topic, stored partition-key bytes, + exact envelope bytes and bounded allowlisted headers. +- [ ] Await the future to a monotonic application deadline and verify non-null metadata. Do not call + per-message `flush()`. `cancel()` is not delivery cancellation evidence. +- [ ] Map Kafka exception categories to stable stage/class/certainty/disposition without exposing + class names or messages. Ambiguity defaults to INDETERMINATE. +- [ ] The adapter returns bounded provider generation, local ACK observation time and safe opaque + record reference. Application never routes from it. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*KafkaProducerFactoryConfigTest' \ + --tests '*SpringKafkaPublishGatewayTest' \ + --tests '*KafkaPublicationFailureClassifierTest' \ + --tests '*AckAwareOutboxPublicationAdapterTest' --console=plain + cd src && ./gradlew verifyDependencyLocks --console=plain + ``` + +- [ ] Acceptance claim: fake-gateway ACK-aware provider candidate; real Kafka ACK remains Task 21. + +**Rollback checkpoint:** producer beans remain gated/dark and v2 scheduler off. + +### Task 16: Add bounded admission, late-completion source and producer generation lifecycle + +**Owner leaves:** `application-core` (`:application-core`), +`adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 15 + +**Files — create:** + +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/PublicationGenerationLifecyclePort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/PublicationGenerationDrainResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RotatePublicationGenerationCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RotatePublicationGenerationResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RotatePublicationGenerationUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RotatePublicationGenerationUseCaseTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/publication/BoundedPublicationAdmission.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/observation/BoundedLatePublicationObservationSource.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerGeneration.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerGenerationManager.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerLifecycle.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/publication/BoundedPublicationAdmissionTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/observation/BoundedLatePublicationObservationSourceTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerGenerationManagerTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGateway.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/publication/AckAwareOutboxPublicationAdapter.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerFactoryConfig.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGatewayTest.java` + +- [ ] RED test finite permit count/queue wait, deadline inclusion, saturation, interrupt, release-on-all + outcomes and zero queued outbox rows after admission exhaustion. +- [ ] Implement one atomic terminal marker per send. If deadline wins, synchronous outcome stays + INDETERMINATE; one later successful callback may enqueue one payload-free late observation. +- [ ] Wire the actual Kafka future callback in `SpringKafkaPublishGateway` to the bounded source, + capturing only stable event ID, delivery generation, publication attempt ID, producer + generation and binding revision before send. Test before-deadline completion, callback-wins, + deadline-wins, duplicate callback, late success, late failure, overflow and generation-close + race against the same atomic terminal marker. +- [ ] RED test observation source lease/poll/ACK/release, bounded capacity, duplicate callback, + timeout-callback race, queue overflow/drop metric and payload/header absence. +- [ ] Overflow never mutates delivery state. It degrades readiness and alerts; capacity + qualification requires zero drop. +- [ ] RED producer generation tests for: + stop admission/claim; bounded drain; unresolved attempts durably reported + INDETERMINATE/HOLD before swap; bounded old close; secret generation resolve; new + create/attest; global generation barrier; no old/new overlap. +- [ ] Keep the messaging implementation provider-only: + `PublicationGenerationLifecyclePort` returns bounded admitted/in-flight resolution facts and + performs pause/drain/create/attest/close, but it imports no outbox store, transaction or + persistence type and never chooses HOLD/retry policy. +- [ ] `RotatePublicationGenerationUseCase` owns orchestration. It pauses new admission/claim through + provider-neutral ports, asks the provider to drain, persists every unresolved durable attempt + as INDETERMINATE and every affected ordered scope as HOLD through + `OutboxDeliveryStorePort`/`TransactionPort`, then permits close/create/attest/barrier switch. + Bootstrap invokes this use case; messaging configuration never calls persistence directly. +- [ ] A DB outage preventing durable INDETERMINATE/HOLD blocks generation switch and keeps + admission closed. Best-effort unresolved sends may return INDETERMINATE but are never + auto-replayed. +- [ ] Fatal producer state blocks new admission, lowers readiness and recreates a new immutable + generation; it does not change acceptance certainty or remove duplicate risk. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*BoundedPublicationAdmissionTest' \ + --tests '*BoundedLatePublicationObservationSourceTest' \ + --tests '*KafkaProducerGenerationManagerTest' --console=plain + cd src && ./gradlew :application-core:test \ + --tests '*RotatePublicationGenerationUseCaseTest' --console=plain + ``` + +- [ ] Acceptance claim: bounded local resource/lifecycle protocol candidate; security/topology and + real broker evidence remain. + +**Rollback checkpoint:** close the dark producer generation and keep canonical scheduler off. + +### Task 17: Attest external topic topology and production Kafka security + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 16 + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecurityProfile.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecuritySettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretReference.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretMaterial.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretMaterialResolver.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaTopicTopologyAttestor.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProvisioningEvidence.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaTopicAttestation.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecuritySettingsTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaTopicTopologyAttestorTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretRedactionTest.java` + +- [ ] RED test typed allowlist: + local-only plaintext, TLS server auth, and production + `SASL_SSL + SCRAM-SHA-512`. First production tuple rejects PLAIN/OAuth/mTLS profiles, + plaintext downgrade, trust-all, hostname verification disable and literal JAAS credentials. +- [ ] `KafkaSecretMaterial` exposes no secret in `toString`, exception, descriptor or log; it carries + bounded generation/expiry and clear/close lifecycle. Configuration carries only + `secret://messaging/kafka/producer`. +- [ ] Freeze the first supported resolver boundary as `mounted-secret-files-v1`. + `KafkaSecretMaterialResolver` accepts only the exact typed reference and returns SCRAM + username/password plus trust material, generation and expiry; no application/shared type + contains these provider details. Unknown scheme/path traversal, missing field, wrong + permission/format, expired generation and literal credential fail closed. +- [ ] RED topology tests for topic existence, partitions, RF, min ISR, cleanup policy, retention, + max bytes, leader/ISR and wrong cluster/binding. +- [ ] Runtime AdminClient uses only bounded `Describe` and exact-topic `DescribeConfigs`. It never + creates/alters/deletes topics, enumerates all ACLs or requires broker-wide configuration. +- [ ] Assert the same resolved generation is applied to both producer factory and AdminClient: + `security.protocol=SASL_SSL`, `sasl.mechanism=SCRAM-SHA-512`, hostname verification enabled + and no literal JAAS value in settings/descriptor/log. A partial producer-only or + AdminClient-only resolution fails startup. +- [ ] Provisioning evidence supplies runtime-inaccessible assertions: + broker policy, auto-create/unclean election, exact positive/negative ACL probes, cluster/topic + resource identity, config/ACL digest, issuer/provenance, generated/expiry time and release + assertion digest. +- [ ] Missing, stale, wrong-cluster, invalid provenance or runtime/provisioning mismatch prevents + `ACTIVE_READY`. Transient broker unavailability yields bounded `ACTIVE_NOT_READY`; static + credential/security/binding errors fail closed. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*KafkaSecuritySettingsTest' \ + --tests '*KafkaTopicTopologyAttestorTest' \ + --tests '*KafkaSecretRedactionTest' --console=plain + ``` + +- [ ] Acceptance claim: local topology/security validation candidate; actual TLS/SASL/ACL and + multi-broker evidence remain Wave E. + +**Rollback checkpoint:** attestation failure keeps relay admission off; it never falls back to topic +auto-create, wildcard ACL or plaintext. + +### Task 18: Expose the authenticated, idempotent disposition endpoint + +**Owner:** `adapter:inbound:web` (`:adapter:inbound:web`) +**Depends on:** Task 12 + +**Files — create:** + +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/controller/MessagingOutboxDispositionController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/dto/request/OutboxDispositionRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/dto/response/OutboxDispositionResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mapper/OutboxDispositionWebMapper.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/controller/MessagingOutboxDispositionControllerWireTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mapper/OutboxDispositionWebMapperTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/controller/MessagingOutboxDispositionOpenApiContractTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/MessagingOutboxDispositionRateLimitTest.java` +- `src/adapter/inbound/web/src/test/resources/openapi/messaging-outbox-disposition-openapi-snapshot.json` + +**Files — modify:** + +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicyTest.java` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` + +- [ ] RED wire tests for: + + ```text + POST /internal/operations/messaging/outbox/{eventId}/dispositions + required header: Idempotency-Key + base permission: outbox:disposition + destructive permission: outbox:disposition:destructive + ``` + +- [ ] Cover unauthenticated, insufficient permission, missing/malformed key, invalid DTO, stale + generation/version, idempotency replay/mismatch, live claim conflict, horizon exceeded, + missing destructive approval/compensation reference and success response. +- [ ] Request contains expected delivery generation, expected row version, closed disposition, + bounded reason and incident/change reference. Mapper converts + `AuthenticatedPrincipal`/request/path/header to framework-free command; no web/security type + crosses into application. +- [ ] Use `IdempotencyKeySupport` to build the existing principal/use-case-scoped + `IdempotencyScope` and compute `RequestFingerprint` from the canonical disposition request + fields, including event ID, expected generation/version, disposition, reason, incident, + approval and compensation reference. Pass the resulting `IdempotencyContext` to the use case; + never pass only a raw header string. +- [ ] Reuse `IdempotencyKeySupport` and existing authorization enforcement. Controller calls only + `ApplyOutboxDispositionUseCase`; it imports no repository, entity, outbound adapter or + transaction manager. +- [ ] Keep the endpoint authenticated and absent from the public-path allowlist. Add explicit + internal network/rate-bound contract and a committed endpoint OpenAPI snapshot. The public + path snapshot must remain unchanged; `verifyPublicPathSnapshot` proves the endpoint was not + accidentally allowlisted. +- [ ] Map stale CAS to conflict, invalid policy to safe 4xx, authorization to existing envelope and + unknown failures to safe 5xx without event payload/hash leakage. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*MessagingOutboxDispositionControllerWireTest' \ + --tests '*OutboxDispositionWebMapperTest' \ + --tests '*MessagingOutboxDispositionOpenApiContractTest' \ + --tests '*MessagingOutboxDispositionRateLimitTest' \ + --tests '*RolePermissionPolicyTest' --console=plain + cd src && ./gradlew verifyPublicPathSnapshot --console=plain + ``` + +- [ ] Acceptance claim: authenticated transport mapping candidate; persistence/application tests + remain authority for policy/CAS. + +**Rollback checkpoint:** disable route exposure through composition/network policy, not by allowing +raw SQL mutation. + +### Task 19: Compose exact tuple, schedulers, readiness and observability + +**Owner:** `app-bootstrap` (`:app-bootstrap`) +**Depends on:** Tasks 14–18 + +**Files — create:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityReadiness.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingRuntimeDescriptor.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MountedKafkaSecretResolverSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MountedKafkaSecretMaterialResolver.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSecretRefreshScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/LatePublicationObservationScheduler.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/PersistenceTransactionResourceDescriptor.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityReadinessTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingRuntimeDescriptorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MountedKafkaSecretMaterialResolverTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/KafkaSecretRefreshSchedulerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/LatePublicationObservationSchedulerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingDisabledZeroResourceContractTest.java` + +**Files — modify:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/test/resources/application-test.yml` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` + +- [ ] RED composition tests prove settings bind/compile before secret/client, then producer, topic + attestation, readiness, v2 relay and late-drain scheduler in that order. +- [ ] Bootstrap aggregates leaf descriptors and same transaction resource identity only. It must not + reimplement catalog/schema/retry/disposition/provider rules. +- [ ] Persistence exposes a sanitized `transactionResourceId` plus resolved + DataSource/EntityManagerFactory/PlatformTransactionManager identity descriptor. Bootstrap + compares it with `TransactionPort`, canonical append adapter and the business repository + resource before creating ACTIVE clients or schedulers. Add a composition RED/GREEN case where + a second DataSource causes startup rejection and Kafka/network resource count remains 0. +- [ ] Implement `mounted-secret-files-v1` in bootstrap with an explicit bounded root, exact + reference-to-directory mapping, no symlink/path escape, owner/permission checks where the + platform exposes them, atomic generation manifest read, expiry validation, redacted failure + and prompt clearing of old char/byte material. The refresh scheduler invokes + `RotatePublicationGenerationUseCase`; it never mutates a live producer object. +- [ ] Missing/wrong/expired secret blocks ACTIVE before producer/AdminClient creation. Refresh + failure may retain the old generation only until its configured expiry/safety margin, then + closes admission/readiness. DISABLED creates resolver/refresh/file-watch resource count 0. +- [ ] Gate the v2 relay and late-drain scheduler on canonical ACTIVE + `POLLING_V2` epoch + fresh + producer/topic/security readiness. Remove the legacy `relay-enabled` boolean from canonical + mode. +- [ ] Exact `LEGACY_POLLING` compatibility composition may expose `LegacyOutboxAppendPort`; + canonical `POLLING_V2` composition must assert legacy append/store/publish/relay bean count 0. +- [ ] Readiness roles stay separate: + + ```text + relay = producer + topic/security + DB claim + catalog + durable write = DB append + backlog capacity + direct required producer = producer/topic/security + liveness = process-internal only + ``` + +- [ ] Add bounded hysteresis/freshness and explicit `STARTING|ACTIVE_NOT_READY|ACTIVE_READY`. + Static configuration/security mismatch fails startup; transient broker outage never starts + relay admission. +- [ ] Runtime descriptor exposes only card IDs, versions, catalog/schema/settings digests, + destination aliases/revisions, resource ID, epoch/authority, generation, readiness, + evidence status, non-guarantees and runbook IDs. Redact servers/topics where policy requires; + never expose credentials/payload/hash/raw headers. +- [ ] Replace legacy metrics with bounded dimensions for logical attempt, certainty, failure stage, + claim conflict/lease/backlog/order block/generation/late-drop. Reject event/aggregate/tenant/ + key/correlation/hash/exception-message tags. One confirmed persisted transition owns the + canonical error. +- [ ] DISABLED integration test asserts client/factory/template/AdminClient/semaphore/queue/thread/ + scheduler/secret resolver/network count 0. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingCapability*Test' \ + --tests '*MessagingRuntimeDescriptorTest' \ + --tests '*MountedKafkaSecretMaterialResolverTest' \ + --tests '*KafkaSecretRefreshSchedulerTest' \ + --tests '*LatePublicationObservationSchedulerTest' \ + --tests '*MessagingDisabledZeroResourceContractTest' \ + --tests '*OutboxConfigTest' \ + --tests '*OutboxSettingsTest' --console=plain + ``` + +- [ ] Acceptance claim: complete dark reference graph candidate; target deployment authority remains + legacy until Task 25. + +**Rollback checkpoint:** keep canonical expected-state DISABLED and `LEGACY_POLLING` ACTIVE. No DB +schema downgrade. + +### Task 20: Implement and rehearse the fenced authority cutover without production switch + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa`, `app-bootstrap`, +`adapter-outbound-messaging` +**Depends on:** Tasks 13–19 +**Base template gate:** fresh or verified empty/drained V3 only + +Every cutover in this task runs against a disposable rehearsal database and test broker. It proves +the code/protocol but does not change a target deployment, start its v2 relay, resume its business +writes or delete legacy runtime. Production remains `LEGACY_POLLING`. + +**Files — create:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxWriteAdmissionControlPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelayControlPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxWriteAdmissionSnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelaySnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionEvidence.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAuthorityCutoverPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxPreCommitRecoveryPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxCutoverPreconditionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxWriteAdmissionEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxRuntimeNodeLeaseEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxWriteAdmissionJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxRuntimeNodeLeaseJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/PostgreSqlOutboxWriteAdmissionGuard.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxWriteAdmissionControlAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxRuntimeNodeLeaseAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxWriteAdmissionControlAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxRuntimeNodeLeaseAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapterTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFence.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFenceTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapter.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRuntimeNodeLeaseScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverJobSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunner.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxRuntimeNodeLeaseSchedulerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunnerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyToV2CutoverContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxWriteAdmissionMultiNodeContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2SentinelContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` +- `src/app-bootstrap/build.gradle` +- `src/build.gradle` + +- [ ] RED application tests scope `FinalizeOutboxAuthorityCutoverUseCase` to the atomic database + finalization contract. It accepts an opaque, human-approved cutover evidence ID and never + trusts command booleans for writer/relay/producer drain. Before reconciliation it must CAS the + exact fresh attempt `CUTOVER_PENDING -> FINALIZING_V2` in the same database transaction that + commits the epoch, after taking the global write-admission/ACTIVE-epoch locks in the fixed + order and proving it is the sole nonterminal `OUTBOX_PUBLICATION` attempt; + `RECOVERING_LEGACY`, an expired attempt or a different operation owner is a hard rejection. + Rollback restores `CUTOVER_PENDING`, while a successful epoch commit records `CONSUMED_V2`. + This is a one-shot maintenance use case, not a second web endpoint. +- [ ] `OutboxLegacyToV2CutoverCoordinator` is the deployment/composition owner. Through + `OutboxWriteAdmissionControlPort`, `LegacyOutboxRelayControlPort` and + `PublicationGenerationLifecyclePort`, it freezes writes, drains writers/relay/futures, + closes/fences legacy Write, compiles/attests the canonical tuple and asks + `OutboxCutoverPreconditionPort` to persist a short-lived one-shot evidence record containing + exact node/writer/relay/producer generations, zero-active facts, epoch, manifest digest, + approver and expiry in `CUTOVER_PENDING`. Evidence creation uses the global lock order + write-admission singleton `FOR UPDATE` then ACTIVE epoch `FOR UPDATE`, requires the exact + FROZEN generation/target binding and rejects any nonterminal attempt in + `OUTBOX_PUBLICATION`; the partial unique constraint is the final concurrent-insert guard. + Bootstrap imports only application ports; it never queries repositories or Kafka adapter + internals. +- [ ] Implement the production write fence with the PostgreSQL singleton created in Task 7. + `SpringTransactionPort.inWrite` begins its transaction, acquires `FOR KEY SHARE` through + `PostgreSqlOutboxWriteAdmissionGuard`, and verifies OPEN + expected fence generation before + invoking any business action. The control adapter takes `FOR UPDATE`, waits for all older + share-holding writers to commit/rollback, writes FROZEN generation and then returns a durable + zero-active snapshot. New `inWrite` calls fail and roll back; `inNew` remains available only + for maintenance/outbox/audit and never bypasses a business write. +- [ ] Implement both explicit generation-CAS exits from FROZEN without a raw status update. + `ResumePollingV2WriteAdmissionUseCase` requires expected frozen generation, exact ACTIVE + `POLLING_V2` epoch, canonical cutover sentinel created through `OutboxAppendAdapter` and + persisted as `DELIVERY_RECORDED`, fresh target binding/readiness and no legacy runtime. It + writes `OPEN(generation+1)` once; mismatch/replay/failure leaves FROZEN. + `RecoverLegacyOutboxAuthorityUseCase` is legal only before epoch commit. Before any external + ACL mutation, its prepare operation takes the same global write-admission/ACTIVE-epoch lock + order, proves the exact attempt is the sole nonterminal authority attempt plus + FROZEN/`LEGACY_POLLING`/zero-v2-authority, then CAS-claims + `CUTOVER_PENDING -> RECOVERING_LEGACY` and atomically invalidates that attempt for v2 + finalization. Choosing this branch is irreversible for that attempt; only recovery completion + or recovery-only lease takeover remains legal. The complete operation follows the separately + fenced recovery protocol below. +- [ ] Register bounded `outbox_runtime_node_lease` heartbeats for every runtime node with node ID, + source/artifact digest, write-fence protocol version, epoch and scheduler roles. Precondition + evidence requires all deployment-inventory instances to have a matching fresh lease and + rejects stale, unknown, pre-fence or missing nodes. A lease table alone does not prove the + absence of an unregistered process; target deployment inventory/provenance is also mandatory. +- [ ] `LegacyOutboxRelayControlAdapter` owns composition of existing runtime controls: + pause new `OutboxRelayScheduler` cycles, wait active cycles/claims to the finite deadline, + pause/drain the epoch-fenced `OutboxReaper`, and close + `LegacyPublicationWriteFence` so `OutboxMessagePublishAdapter` rejects every later send. + Snapshot counts/generations are bounded facts only. Reopen methods require expected component + generations plus fresh pre-commit recovery evidence and reject once ACTIVE epoch is not + `LEGACY_POLLING`; no generic boolean setter exists. Application cutover policy sees the port, + not concrete scheduler/reaper/messaging types. +- [ ] The disposable security rehearsal and actual target preflight use distinct legacy/canonical + principals. Revoke legacy exact-topic Write and require a negative Write probe while canonical + Describe/DescribeConfigs/Write stays positive. The in-process fence plus external ACL evidence + are both required; neither substitutes for the other. +- [ ] Add the exact non-web one-shot operational entrypoint + `MessagingAuthorityCutoverApplicationRunner`. It activates only for + the closed operations `legacy-to-polling-v2`, `recover-legacy-precommit`, or + `resume-polling-v2-writes`. It requires opaque operation/approval-evidence IDs plus expected + target/source/artifact/epoch/fence generation, invokes only the corresponding application use + case/coordinator, emits no payload/secret, and exits non-zero on mismatch/replay/failure. The + main cutover exits 0 only after sentinel/readiness proof and write admission + `OPEN(generation+1)`; a post-commit resume failure stays FROZEN and requires the separately + one-shot `resume-polling-v2-writes` operation. Consumed DB evidence/operation IDs make retries + non-reentrant; no controller endpoint is added. + + ```text + ca-skeleton.messaging.maintenance.operation + ca-skeleton.messaging.maintenance.operation-id + ca-skeleton.messaging.maintenance.approval-evidence-id + ca-skeleton.messaging.maintenance.expected-target-alias + ca-skeleton.messaging.maintenance.expected-source-digest + ca-skeleton.messaging.maintenance.expected-artifact-digest + ca-skeleton.messaging.maintenance.expected-epoch + ca-skeleton.messaging.maintenance.expected-fence-generation + ``` + + Task 23 registers these exact maintenance-only keys and the runbook's non-web launcher + contract; none has a default that enables the runner. +- [ ] In the rehearsal harness: + deploy ACK-aware producer/v2 relay scheduler-disabled; compile the candidate tuple; prove + disposition auth/CAS negatives; execute the real PostgreSQL business-write admission freeze; + drain active writers/epoch share holders; stop legacy new claims and legacy reaper; drain + `IN_FLIGHT` to the maximum budget; audit remaining indeterminate; close/fence legacy producer + Write and DB legacy mutation. +- [ ] Real multi-node PostgreSQL tests hold old `inWrite` transactions across freeze, start new + writers during/after freeze, inject a stale/pre-fence node lease and omit a deployment + inventory member. Freeze must wait for old holders, reject new writes without partial business/ + outbox state, and refuse evidence until every live instance/fence/relay/reaper/producer fact is + exact and zero-active. +- [ ] In one PostgreSQL transaction: + + ```text + lock OUTBOX_PUBLICATION write-admission singleton FOR UPDATE + -> lock ACTIVE LEGACY_POLLING epoch FOR UPDATE + -> assert exact FROZEN generation/target binding and sole nonterminal attempt + -> lock exact fresh cutover attempt + -> CAS CUTOVER_PENDING -> FINALIZING_V2 + -> assert writer/legacy mutation fences + -> capture fixed legacy handoff watermark + -> final reconcile every row through watermark including final delta + -> assert exactly one CURRENT delivery per event, active claims 0 + -> assert row count + event ID/hash manifest, unmapped/duplicate count 0 + -> switch ACTIVE epoch LEGACY_POLLING -> POLLING_V2 + -> append v2 cutover sentinel through the canonical append adapter + with retained V3 projection + CURRENT/READY delivery + -> mark the same attempt CONSUMED_V2 + -> commit + ``` + +- [ ] Missing, expired, reused, wrong-epoch/generation, wrong-manifest or non-zero cutover evidence + rolls back before reconciliation. `RECOVERING_LEGACY`, `RECOVERED_LEGACY`, a foreign recovery + owner or any non-`CUTOVER_PENDING` state also rejects finalization. The final transaction + marks the evidence consumed; the coordinator cannot replay it. +- [ ] Migration-only state mapping is exact: + + ```text + PENDING -> READY + FAILED -> RETRY_WAIT with reviewed DB-time due/budget + DEAD -> EXHAUSTED + PUBLISHED -> LEGACY_RECORDED_UNVERIFIED + IN_FLIGHT -> HOLD + remaining-indeterminate audit + ``` + + Preserve reviewed attempt count/due/deadline and every historical observation; never + fabricate broker metadata, definite rejection, ACK observed time or `DELIVERY_RECORDED`. +- [ ] Any unknown contract/status/hash mismatch, duplicate current row, count/manifest mismatch, + active claim, fence failure or sentinel failure rolls the whole transaction back and leaves + `LEGACY_POLLING` authoritative. +- [ ] In the disposable rehearsal only, after commit start v2 relay, require sentinel + ACK/`DELIVERY_RECORDED`, prove the sentinel used the canonical append path and retained V3 NOT + NULL projection, verify legacy writer/claim/reaper/send 0, then execute the exact + FROZEN→OPEN generation CAS. Only after OPEN, exercise an ordinary `TransactionPort.inWrite` + canonical append; failure immediately freezes a new generation and fails the rehearsal. +- [ ] RED/GREEN fault cases at every numbered point, including crash before transaction, after + watermark, during final delta, before epoch switch, before/after sentinel insert and after + commit. No case permits dual authority or missing manifest row. +- [ ] Rehearse the two authority zones and both pre-commit choices: + + ```text + pre-commit: + keep all fences closed + -> bounded forward retry while evidence/approval remains fresh + OR + prove epoch still LEGACY_POLLING + v2 business send/sentinel authority 0 + exact inventory + -> DB-CAS exact attempt CUTOVER_PENDING -> RECOVERING_LEGACY + and atomically invalidate it for every forward finalizer + -> externally regrant legacy exact-topic Write and verify a fresh positive probe + -> re-lock attempt + epoch and revalidate RECOVERING_LEGACY owner/lease + LEGACY_POLLING + (on mismatch immediately revoke legacy Write and prove a fresh negative probe) + -> append immutable recovery/ACL audit + -> reopen in-process legacy Write fence, reaper and relay with expected generations + -> CAS write admission FROZEN -> OPEN(generation+1) and mark RECOVERED_LEGACY last + post-commit, regardless of whether a business v2 send occurred: + legacy reactivation/reverse epoch is unsupported + -> keep write admission FROZEN + -> after sentinel/readiness/canonical-projection proof, CAS OPEN(generation+1) + -> otherwise preserve backlog/schema/epoch/audit and forward-fix + ``` + + Once `RECOVERING_LEGACY` is claimed, no forward retry or separately created attempt can commit + in `OUTBOX_PUBLICATION`, including after recovery lease expiry. Inject barrier races in both + lock orders for same-attempt finalization versus recovery prepare, different-attempt creation/ + finalization versus recovery prepare/completion, plus crash/abort before and after the durable + recovery claim, external ACL regrant, post-ACL epoch revalidation, each component reopen and + final admission CAS. + Wrong epoch/inventory/ACL evidence, stale generation, partial legacy reopen, duplicate + operation, resume-before-sentinel and DB failure must never open business writes. If + post-ACL revalidation fails, immediately revoke legacy Write and require a new negative probe; + if a legacy component was reopened but final admission CAS failed, business writes stay + FROZEN and the coordinator re-fences or records the exact safe degraded state for idempotent + recovery-only retry. +- [ ] Verify: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*FinalizeOutboxAuthorityCutoverUseCaseTest' \ + --tests '*ResumePollingV2WriteAdmissionUseCaseTest' \ + --tests '*RecoverLegacyOutboxAuthorityUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxAuthorityCutoverAdapterTest' \ + --tests '*OutboxWriteAdmissionControlAdapterTest' \ + --tests '*OutboxRuntimeNodeLeaseAdapterTest' \ + --tests '*OutboxPreCommitRecoveryAdapterTest' \ + --tests '*SpringTransactionPortTest' --console=plain + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*LegacyPublicationWriteFenceTest' \ + --tests '*OutboxMessagePublishAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxLegacyToV2CutoverCoordinatorTest' \ + --tests '*OutboxLegacyPreCommitRecoveryCoordinatorTest' \ + --tests '*LegacyOutboxRelayControlAdapterTest' \ + --tests '*OutboxRuntimeNodeLeaseSchedulerTest' \ + --tests '*MessagingAuthorityCutoverApplicationRunnerTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxLegacyToV2CutoverContractTest' \ + --tests '*OutboxWriteAdmissionMultiNodeContractTest' \ + --tests '*OutboxV2SentinelContractTest' --console=plain + ``` + +- [ ] After the disposable RED/GREEN matrix passes, schema-validate and write: + + ```text + src/app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json + ``` + + It binds the supplied source/artifact digest, migration/card/profile/catalog/settings hashes, + disposable database/broker identity, every fault point and rollback-zone scenario, exact row/ + manifest/sentinel assertions, pre-commit recovery and post-commit resume generation-CAS + scenarios, commands/timestamps, failed=0, skipped=0 and the explicit non-claim + `targetDeploymentCutOver=false`. It conforms to the common build-evidence schema. +- [ ] Acceptance claim: cutover implementation and disposable fault rehearsal candidate only. + Target deployments remain `LEGACY_POLLING`; no legacy code/config is deleted and the tuple + remains non-R2 until Wave E evidence. + +**Rollback checkpoint:** discard the rehearsal database/broker. Never apply rehearsal evidence as a +target deployment switch or destructively downgrade schema. + +### Wave D exit checkpoint + +- [ ] Run focused owner checks and: + + ```bash + cd src && ./gradlew verifyMessagingContracts \ + verifyMessagingJsonSchemaV1 \ + verifyMessagingPollingOutboxR2 \ + verifyCleanArchitectureDependencies \ + verifyEnvKeys \ + verifyPublicPathSnapshot \ + --console=plain + ``` + +- [ ] Keep Kafka/security cards at most `implemented-candidate`. +- [ ] Update the design ledger to `P3=IMPLEMENTED_CANDIDATE` only after the dark graph and disposable + cutover rehearsal pass; target authority is still legacy. +- [ ] Update the LLM Wiki branch-note and derived-document decision. + +--- + +## Wave E — P4 real-service, security, fault and release qualification + +### Task 21: Prove real PostgreSQL + real Kafka reference behavior + +**Owner:** `app-bootstrap` qualification tests +**Depends on:** Wave D + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingKafkaR2ContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingPollingKafkaEndToEndContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingKafkaFaultContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingKafkaContainerSupport.java` +- `src/app-bootstrap/src/test/resources/messaging/evidence/messaging-evidence-schema-v1.json` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `src/build.gradle` + +- [ ] Add test-only: + + ```groovy + testImplementation 'org.testcontainers:testcontainers-kafka' + testImplementation 'org.testcontainers:testcontainers-toxiproxy' + testImplementation 'org.springframework.kafka:spring-kafka-test' + ``` + + Pin container image digest in qualification settings and record broker/client/Spring + versions. +- [ ] Register `:app-bootstrap:messagingKafkaProducerR2` with these exact filters and + `failOnNoMatchingTests=true`: + + ```text + dev.caskeleton.bootstrap.integration.messaging.MessagingKafkaR2ContractTest + dev.caskeleton.bootstrap.integration.messaging.MessagingPollingKafkaEndToEndContractTest + dev.caskeleton.bootstrap.integration.messaging.MessagingKafkaFaultContractTest + ``` + + Root `verifyMessagingKafkaProducerR2` depends on that Test task and validates its evidence. + Docker/image pull/test skip is failure, not PASS. +- [ ] After registering the task but before implementing the three tests, run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingKafkaProducerR2 --console=plain + ``` + + Expected non-zero: no matching required tests or absent real-service evidence. Any unrelated + compile failure must be fixed before proceeding. +- [ ] Real Kafka RED/GREEN cases: + actual topic/partition/offset metadata; expected-topic mismatch; `acks=all`/idempotence + effective config; stable key/partition; header/record oversize; missing topic with auto-create + disabled; broker unavailable before send; leader/retriable failure; response loss/deadline/ + late ACK; local buffer saturation/max-block; throttle; no per-message flush; graceful/forced + close; fatal generation recreation. +- [ ] Combined real PostgreSQL + Kafka cases: + event/delivery commit; JIT claim/admission; ACK → delivery CAS; ACK-to-DB crash/reclaim + duplicate; stale token after late ACK; outcome commit failure; late DB-commit-before-source-ACK + duplicate absorption; backlog outage/recovery; multi-worker disjoint claim/order/fairness. +- [ ] Add the rolling-compatibility golden path against the real broker: canonical append while + `LEGACY_POLLING` is active → legacy claim → broker-observed exact compiled topic, stored key and + byte-identical v1 envelope → legacy terminal `PUBLISHED` → disposable cutover maps + `LEGACY_RECORDED_UNVERIFIED` with no automatic v2 resend. Nested envelope, legacy event-type + routing for a canonical row and mixed metadata are negative cases. +- [ ] Fault injection must observe both broker event IDs and DB state at: + + ```text + after event commit + after claim commit + before send + after request write + after broker append before ACK receipt + after ACK before DB transition + during DB transition commit + after DB success before scheduler result + during shutdown + ``` + +- [ ] Single-node evidence is labelled provider baseline only. It cannot satisfy RF/min ISR, + leader-loss or production security rows. +- [ ] Generate a sanitized manifest at the non-versioned exact path: + + ```text + src/app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json + ``` + + Validate it against + `src/app-bootstrap/src/test/resources/messaging/evidence/messaging-evidence-schema-v1.json`. + Also validate the same bytes against + `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json`; the lane schema may add + fields but cannot weaken the common source/artifact/scenario/failure/skip contract. + CI retains the same bytes under + `ci-artifact://messaging/{sourceDigest}/real-kafka-postgresql-r2/manifest.json`. The manifest + contains source/artifact digest supplied by human/CI, commands/timestamps, + test counts, versions/image digests, non-secret effective settings, hashes, scenarios/results, + skips/failures, unsupported claims and runbook IDs. +- [ ] Re-run GREEN: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingKafkaProducerR2 \ + verifyMessagingKafkaProducerR2 \ + verifyMessagingPollingOutboxR2 --console=plain + ``` + + Expected: all listed scenario IDs occur exactly once, failed=0, skipped=0, schema validation + PASS and source/artifact digests match. +- [ ] Acceptance claim: real single-node Kafka + PostgreSQL R2-candidate evidence; production tuple + remains NOT_QUALIFIED. + +**Rollback checkpoint:** qualification uses disposable services. Production activation is still +blocked by Task 22. + +### Task 22: Qualify SASL_SSL/SCRAM, least privilege and multi-broker topology + +**Owner:** deployment/security qualification lane + `app-bootstrap` aggregator +**Depends on:** Task 21 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/qualification/messaging/MessagingSecurityR2QualificationTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/qualification/messaging/MessagingMultiBrokerR2QualificationTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/qualification/messaging/MessagingRotationShutdownQualificationTest.java` +- `src/app-bootstrap/src/test/resources/messaging/qualification/docker-compose.kafka-r2.yml` +- `src/app-bootstrap/src/test/resources/messaging/qualification/README.md` +- `src/config/messaging/evidence/messaging-release-evidence-schema-v1.json` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/build.gradle` +- `src/config/messaging/release-profile-assertions.yaml` + +- [ ] Register three non-ordinary Test tasks with exact filters and + `failOnNoMatchingTests=true`: + + ```text + :app-bootstrap:messagingSecurityR2 + -> dev.caskeleton.bootstrap.qualification.messaging.MessagingSecurityR2QualificationTest + :app-bootstrap:messagingMultiBrokerR2 + -> dev.caskeleton.bootstrap.qualification.messaging.MessagingMultiBrokerR2QualificationTest + :app-bootstrap:messagingRotationShutdownR2 + -> dev.caskeleton.bootstrap.qualification.messaging.MessagingRotationShutdownQualificationTest + ``` + + Root `verifyMessagingSecurityR2` depends on all three evidence validators. Missing topology, + credential fixture, certificate, Docker/image or tests fails the release task. +- [ ] After task registration but before the qualification environment/tests are complete, run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingSecurityR2 \ + :app-bootstrap:messagingMultiBrokerR2 \ + :app-bootstrap:messagingRotationShutdownR2 \ + --console=plain + ``` + + Expected non-zero for an exact missing test/topology/security prerequisite. SKIPPED is not an + accepted RED or GREEN result. +- [ ] Use a pinned three-broker topology with RF=3/min ISR=2 and production-like + SASL_SSL/SCRAM-SHA-512. Ephemeral test credentials/certificates never enter source/evidence. +- [ ] Security positive/negative cases: + trusted TLS; untrusted CA; hostname mismatch; expired/not-yet-valid cert; valid/invalid SCRAM; + missing/expired secret; production plaintext rejection; redaction; exact-topic Describe/ + DescribeConfigs/Write; denied Create/Delete/Alter/other-topic Write/consumer Read. Use distinct + canonical and legacy fixture principals and prove the legacy principal's exact-topic Write can + be revoked without removing canonical Describe/DescribeConfigs/Write. +- [ ] Topology cases: + expected partitions/RF/min ISR; cleanup/retention/max bytes drift; wrong cluster/topic; + auto-create disabled; leader loss with ISR sufficient; below-min-ISR rejection/indeterminate + mapping; recovery; provisioning evidence freshness/provenance. +- [ ] Provisioning evidence includes the selected broker's + `replica.lag.time.max.ms`, the producer's effective `request.timeout.ms` and the approved + compatibility relation from design §16.6. Add a mismatch negative case; do not infer the + broker value from a client default. +- [ ] Rotation/lifecycle cases: + stop admission; bounded old drain; forced unresolved → durable INDETERMINATE/HOLD; old close; + new secret/producer/attestation; generation barrier; no old/new overlap; DB-unavailable switch + rejection; shutdown under load. +- [ ] Capacity/soak cases: + sustained drain, hot aggregate, broker throttle/outage/recovery storm, retry amplification, + producer memory/buffer/GC, DB pool/claim query, metric cardinality and late-observation drop 0. + Record numbers as selected-environment evidence, not universal repository performance claims. +- [ ] Emit and schema-validate these non-versioned exact files: + + ```text + src/app-bootstrap/build/messaging-evidence/security-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json + ``` + + Validate each byte-identical file against both + `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` and the stricter + `src/config/messaging/evidence/messaging-release-evidence-schema-v1.json`. Add a contract test + proving the lane schema retains every common required field and rejection rule. + CI retains byte-identical artifacts under the source-digest-qualified + `ci-artifact://messaging/` namespace. Each manifest must match exact source/artifact digest, + `qualificationEnvironmentIdentity` (fixture broker/image/principal provenance), topic/security + profile, card/settings/catalog/schema hashes, scenario set and freshness window. Any mismatch + or skip keeps all affected cards `implemented-candidate`. This identity is never reused as a + target `deploymentBindingIdentity`; only capability/profile, supported broker/client version + constraints, settings/catalog/schema and scenario-contract revisions are portable. +- [ ] Re-run GREEN: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingSecurityR2 \ + :app-bootstrap:messagingMultiBrokerR2 \ + :app-bootstrap:messagingRotationShutdownR2 \ + verifyMessagingSecurityR2 --console=plain + ``` + + Expected: every required scenario ID exactly once, failed=0, skipped=0, all three manifests + pass `messaging-release-evidence-schema-v1.json`, and all source/artifact/profile digests + match. +- [ ] Acceptance claim: exact production security/topology candidate only after all required + scenarios PASS. No consumer/CDC claim. + +**Rollback checkpoint:** failed qualification prevents release promotion; do not weaken RF/min ISR, +ACL, TLS or card requirements to make the lane green. + +### Task 23: Synchronize configuration, registries, runbooks and operational truth + +**Owner:** repository documentation/configuration +**Depends on:** Tasks 19–22 + +**Files — modify:** + +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/test/resources/application-test.yml` +- `src/.env` +- `docs/registries/env-keys.yaml` +- `docs/registries/capabilities.yaml` +- `docs/registries/error-codes.yaml` +- `docs/registries/metrics.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` +- `src/shared-contract/README.md` +- `src/shared-contract/CLAUDE.md` +- `src/adapter/outbound/messaging/README.md` +- `src/adapter/outbound/messaging/CLAUDE.md` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` +- `docs/runbooks/outbox-publish-failed.md` +- `docs/runbooks/outbox-dead-letter.md` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java` + +**Files — create:** + +- `docs/runbooks/messaging-producer-unavailable-or-unauthorized.md` +- `docs/runbooks/messaging-outbox-backlog-and-stale-lease.md` +- `docs/runbooks/messaging-delivery-indeterminate-and-duplicate-burst.md` +- `docs/runbooks/messaging-schema-poison-or-record-too-large.md` +- `docs/runbooks/messaging-terminal-delivery-disposition.md` +- `docs/runbooks/messaging-topic-policy-or-partition-change.md` +- `docs/runbooks/messaging-shutdown-deploy-and-secret-rotation.md` +- `docs/runbooks/messaging-legacy-to-v2-relay-authority-cutover.md` + +- [ ] Modify the three listed contract tests first, then run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*RunbookCoverageContractTest' \ + --tests '*OutboxStatusRegistryContractTest' \ + --tests '*EventPayloadPiiContractTest' --console=plain + ``` + + Expected non-zero because new status/key/metric/error/runbook/redaction entries are absent. + Register owner, type/default/allowlist, secret classification, validation, compatibility + impact and required test for each implemented key. +- [ ] Remove canonical reliance on: + + ```text + APP_MESSAGING_BROKER + APP_MESSAGING_KAFKA_BROKERS + ca-skeleton.outbox.relay-enabled + ``` + + in the canonical graph. Retain them only as explicit R0 compatibility inputs through the + deployment observation window; legacy + canonical keys fail with no silent precedence. Final + removal belongs to Task 26. Do not add `consumer.enabled`, `cdc.enabled` or + `schemaRegistry.url`. +- [ ] Keep base skeleton default DISABLED with active contracts/destinations/resource count 0. + Deployment-specific destination/topic/security values are explicit placeholders or secret + references, never usable credentials. +- [ ] Replace producer `DEAD/dead-letter` vocabulary with `EXHAUSTED`; distinguish it from future + consumer DLT. Remove raw SQL status rewrite and fabricated consumer-dedupe claims from old + runbooks. +- [ ] Each first-R2 runbook contains detection, blast radius, guarantee degradation, safe first + response, evidence, non-destructive mitigation, destructive approval boundary, + reconciliation, recovery proof, rollback, audit and related cards/metrics/errors. +- [ ] The authority-cutover runbook contains the exact pre-commit bounded-forward-retry and + abort-to-legacy recovery state machine, the irreversible + `CUTOVER_PENDING -> RECOVERING_LEGACY` claim before external mutation, recovery-only lease + takeover, the `OUTBOX_PUBLICATION` sole-nonterminal-attempt constraint and global lock order, + same-/cross-attempt finalizer rejection, external legacy ACL regrant/positive probe, post-ACL + epoch revalidation and revoke/negative-probe compensation, expected generation ordering, + partial-reopen re-fence behavior, and the post-commit `resume-polling-v2-writes` path. It + explicitly forbids any post-commit legacy reactivation or raw admission/epoch SQL. +- [ ] Document exact state/table/class names, token/generation/audit operator API, role readiness, + no-dual-authority cutover and non-guarantees. No stub alert/dashboard references count as + evidence. +- [ ] Re-run the same three tests GREEN after registries/runbooks are complete, then verify global + drift gates: + + ```bash + cd src && ./gradlew verifyEnvKeys \ + verifyPublicPathSnapshot \ + :app-bootstrap:test \ + --tests '*RunbookCoverageContractTest' \ + --tests '*OutboxStatusRegistryContractTest' \ + --tests '*EventPayloadPiiContractTest' \ + --console=plain + ``` + + Expected: all three contract tests PASS, no skip, and env/public-path verification PASS. + +- [ ] Acceptance claim: documentation/configuration reflects actual implementation and evidence; + unexecuted lanes remain NOT_QUALIFIED. + +**Rollback checkpoint:** docs describe deployed/evidenced truth, not preferred state. Never rewrite +failed evidence or instruct operators to dual-send/raw-update. + +### Task 24: Freeze and aggregate the pre-cutover release candidate + +**Owner:** repository-wide verification and documentation +**Depends on:** every selected Task 1–23 requirement + +**Files — modify:** + +- `docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md` +- `docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md` +- `src/config/messaging/readiness-cards.yaml` +- `src/config/messaging/release-profile-assertions.yaml` +- `src/build.gradle` + +**Files — create:** + +- `src/config/messaging/evidence/deployment-rollout-manifest-v1.schema.json` +- `src/config/messaging/evidence/deployment-binding-attestation-v1.schema.json` +- `src/config/messaging/evidence/final-r2-profile-v1.schema.json` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingDeploymentRolloutEvidenceContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingFinalR2ProfileContractTest.java` + +- [ ] Freeze the candidate source tree and dependency locks. A human supplies the candidate + commit/source digest; CI builds the exact artifact. Agent never stages, commits or pushes. +- [ ] Make `verifyMessagingReleaseProfile` consume these exact build outputs: + + ```text + src/build/messaging-evidence/contracts-schema/manifest.json + src/app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json + src/app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/security-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json + ``` + + Each producer validates its schema before writing. The aggregator verifies all required + scenario IDs, source/artifact digest, card/profile/catalog/schema/settings hashes, cluster/ + qualification-environment identity, freshness, failed=0 and skipped=0, then writes: + + ```text + src/build/reports/messaging/release-profile/manifest.json + ``` + + CI retains the exact bytes under a source-digest-qualified + `ci-artifact://messaging/` release-profile path. +- [ ] Revalidate every one of the seven inputs against the common build-evidence schema and its + lane-specific schema. Add contract fixtures proving a lane schema cannot omit or relax common + source/artifact/scenario/failure/skip fields. +- [ ] Implement fail-closed deployment/final gate contracts before any target cutover: + `verifyMessagingTargetBindingPreflight` validates a non-mutating target preflight; + `verifyMessagingTargetBinding` consumes the fresh target-specific topology/security/ACL + attestation created inside maintenance after the legacy Write fence; + `verifyMessagingDeploymentCutover` consumes that immutable original attestation plus the exact + local target rollout manifest; `verifyMessagingCleanupTargetBinding` consumes a distinct + cleanup-artifact attestation; `verifyMessagingFinalR2Profile` requires both attestations, the + qualified cleanup release manifest, original deployment-cutover manifest and cleanup-rollout + manifest, validates the final-profile schema and writes the final aggregate. Missing/stale/ + wrong-target/wrong-digest/failed/skipped evidence is non-zero. +- [ ] Run the gate contract tests RED then GREEN with checked-in invalid/valid payload-free fixtures: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingDeploymentRolloutEvidenceContractTest' \ + --tests '*MessagingFinalR2ProfileContractTest' --console=plain + ``` + + RED is an intentionally invalid fixture accepted or a missing required validator; GREEN means + every invalid fixture is rejected and every exact valid fixture is accepted. This does not + create target rollout evidence. +- [ ] Run focused owner gates sequentially: + + ```bash + cd src && ./gradlew :application-core:check \ + :shared-contract:check \ + :adapter:outbound:messaging:check \ + :adapter:outbound:persistence-jpa:check \ + :adapter:inbound:web:check \ + :app-bootstrap:check \ + :sample-portfolio:check \ + --console=plain + ``` + +- [ ] Run Messaging gates: + + ```bash + cd src && ./gradlew verifyMessagingContracts \ + verifyMessagingJsonSchemaV1 \ + verifyMessagingPollingOutboxR2 \ + verifyMessagingKafkaProducerR2 \ + verifyMessagingSecurityR2 \ + verifyMessagingReleaseProfile \ + --console=plain + ``` + + Expected: every exact input manifest exists and validates; no mismatch/stale/skip; aggregate + manifest PASS. Missing service/credential/image/test is non-zero, never PASS. +- [ ] Run repository gates: + + ```bash + cd src && ./gradlew test --console=plain + cd src && ./gradlew check --console=plain + cd src && ./gradlew verifyDependencyLocks --console=plain + cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain + cd src && ./gradlew verifyPublicPathSnapshot --console=plain + cd src && ./gradlew verifyEnvKeys --console=plain + cd src && ./gradlew verifyOneTypePerFile \ + verifyApplicationCoreDependencyPurity --console=plain + git diff --check + ``` + +- [ ] Perform independent reviews for: + Clean Architecture/module ownership; schema/contract evolution; transaction/concurrency/CAS; + Kafka outcome/lifecycle; security/topology; operator endpoint; migration/cutover/rollback; + evidence/no-skip/operations. Candidate qualification requires blocker 0 and high 0. +- [ ] From the aggregate only, promote the exact artifact/card rows to `release-eligible`. Record + §0 as `P4=RELEASE_CANDIDATE_QUALIFIED_DEPLOYMENT_NOT_CUT_OVER`; target publication authority + and production runtime remain legacy. P5/P6 stay `DESIGNED_NOT_IMPLEMENTED`, P7 stays + `OPTIONAL_BACKLOG`. +- [ ] Update the branch-note with release-candidate evidence and explicitly state target + cutover/cleanup are pending. Do not make the final implementation-complete claim. + +**Rollback checkpoint:** if aggregation fails, keep cards `implemented-candidate`, target +`LEGACY_POLLING`, and preserve schema/backlog/evidence. Never weaken a gate or copy evidence from +another artifact. + +### Task 25: Execute the approved target deployment cutover + +**Owner:** deployment coordinator + application/persistence cutover protocol +**Depends on:** Tasks 23–24, human deployment approval, exact fresh/empty-drained migration card +**Source change:** none + +- [ ] Fail before maintenance unless the target matches the exact Task 24 source/artifact digest, + release profile, schema/catalog/settings hashes, supported broker/client constraints and the + checked-in cutover runbook. Do not require the target cluster/principal to equal Task 22's + qualification fixture. A live non-empty V3 target stops for a separate approved deployment + migration plan. +- [ ] Against the actual target, resolve the exact canonical secret generation and run a + non-mutating fresh topology/security preflight. Do not revoke the still-authoritative legacy + principal before maintenance. Bind the prepared exact ACL change/provenance and emit identical + sanitized bytes: + + ```text + src/app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/target-binding-preflight/manifest.json + ``` + + `deploymentBindingIdentity` binds target cluster/topic/canonical and legacy principal + identities, canonical secret generation, provisioning provenance and prepared mutation. It + proves canonical Describe/DescribeConfigs/Write and validates the target constraints, but + explicitly records `legacyWriteRevoked=false`; it is not cutover evidence. +- [ ] Run before maintenance: + + ```bash + cd src && ./gradlew verifyMessagingTargetBindingPreflight --console=plain + ``` + + Expected GREEN only for a fresh exact target/source/artifact/release/profile binding with + failed=0 and skipped=0. Fixture identity cannot satisfy this gate. +- [ ] Before target execution, run the fail-closed rollout gate once with no current rollout + artifact: + + ```bash + cd src && ./gradlew verifyMessagingDeploymentCutover --console=plain + ``` + + Expected non-zero: the final in-maintenance target attestation and target rollout artifact are + absent. A stale prior-target artifact must fail for target/source/release-digest mismatch, not + satisfy this RED. +- [ ] Execute the runbook preflight: + deploy candidate with v2 relay scheduler-disabled; attest exact tuple; prove disposition + auth/CAS; freeze durable business-write admission; drain writers/epoch holders; stop/drain + legacy claim and reaper; record remaining IN_FLIGHT as indeterminate/HOLD; fence legacy + producer Write and DB mutation. Only after zero-active drain, apply the prepared ACL mutation, + require legacy exact-topic Write negative and canonical Describe/DescribeConfigs/Write + positive, then write and internally schema-validate: + + ```text + src/app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/target-binding-attestation/manifest.json + ``` + + The one-shot precondition evidence binds this attestation digest. The maintenance runner uses + the same fail-closed validator as `verifyMessagingTargetBinding` before it may call finalization; + raw server, credential and certificate bytes are excluded. +- [ ] Invoke the exact non-web `MessagingAuthorityCutoverApplicationRunner` operation + `legacy-to-polling-v2` with opaque operation/approval-evidence IDs and expected + target/source/artifact/epoch. It calls the coordinator and + `FinalizeOutboxAuthorityCutoverUseCase`; the single transaction repeats the Task 20 + `CUTOVER_PENDING -> FINALIZING_V2` CAS, + watermark/final-delta/manifest/current-delivery assertions, epoch switch, sentinel insert and + `CONSUMED_V2` transition. `RECOVERING_LEGACY` is rejected before reconciliation. Any mismatch + exits non-zero and rolls back to legacy authority. Reusing the operation or consumed evidence + ID exits non-zero without mutation. + + ```text + --spring.main.web-application-type=none + --ca-skeleton.messaging.maintenance.operation=legacy-to-polling-v2 + --ca-skeleton.messaging.maintenance.operation-id={opaqueOperationId} + --ca-skeleton.messaging.maintenance.approval-evidence-id={opaqueApprovalEvidenceId} + --ca-skeleton.messaging.maintenance.expected-target-alias={targetAlias} + --ca-skeleton.messaging.maintenance.expected-source-digest={sourceDigest} + --ca-skeleton.messaging.maintenance.expected-artifact-digest={artifactDigest} + --ca-skeleton.messaging.maintenance.expected-epoch={legacyEpoch} + --ca-skeleton.messaging.maintenance.expected-fence-generation={openFenceGeneration} + ``` +- [ ] If finalization fails before epoch commit, keep every fence closed. Either retry forward within + the still-fresh bounded evidence/approval window, or execute the approved abort-to-legacy + protocol. The latter first proves epoch still `LEGACY_POLLING`, v2 business send/sentinel + authority 0 and exact inventory, then runs the prepare half of + `recover-legacy-precommit`: DB-CAS the exact attempt + `CUTOVER_PENDING -> RECOVERING_LEGACY`, bind the recovery operation/lease/evidence digest and + atomically make every forward finalizer reject it. This choice is irreversible for that + attempt, including after lease expiry. The prepare transaction holds the global + write-admission/ACTIVE-epoch locks in order and proves the partial-unique-protected attempt is + the sole nonterminal `OUTBOX_PUBLICATION` attempt. Only then may external provisioning + regrant legacy exact-topic Write and emit a fresh positive probe. The completion half + re-locks the global authority, exact attempt and epoch after that external mutation; + owner/lease, uniqueness or `LEGACY_POLLING` mismatch immediately re-revokes legacy Write, + proves a fresh negative probe and leaves writes FROZEN. + On success it appends recovery/ACL audit, reopens the in-process legacy Write fence/reaper/ + relay, then CAS-opens durable writes and marks `RECOVERED_LEGACY` last. Emit: + + ```text + src/app-bootstrap/build/messaging-evidence/precommit-legacy-recovery/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/precommit-legacy-recovery/manifest.json + ``` + + Validate identical bytes against the common and deployment-rollout schemas with + `outcome=ABORTED_PRECOMMIT`, exact restored legacy/write-admission generations, recovery + scenario IDs exactly once, failed=0 and skipped=0. + Any partial failure leaves writes FROZEN and is retried/re-fenced; recovery lease takeover is + recovery-only and never restores forward-finalization eligibility. Race tests must run + recovery-prepare versus same- and different-attempt creation/finalization in both lock orders, + and crash tests must cover every boundary before/after claim, ACL regrant, epoch revalidation, + component reopen and final admission CAS. The protocol never uses raw SQL. An aborted attempt + ends Task 25 without cutover; another attempt is rejected until recovery atomically records + `RECOVERED_LEGACY` and opens a new fence generation, then requires fresh preflight, approval + and target attestation. +- [ ] After epoch commit, start only v2 relay while writes remain FROZEN. Require the canonical + sentinel `DELIVERY_RECORDED`, retained V3 projection, fresh target binding/readiness and legacy + writer/claim/reaper/send 0. Then `ResumePollingV2WriteAdmissionUseCase` CAS-opens + `OPEN(generation+1)`. If the original runner dies or resume fails after commit, + `resume-polling-v2-writes` is the only recovery operation; it rechecks the same facts and + cannot reactivate legacy. After OPEN, exercise an ordinary canonical append; failure freezes a + new generation and keeps the rollout non-healthy. +- [ ] Apply the rehearsed rollback zones exactly: + pre-commit failure retains legacy DB authority but stays in maintenance until bounded forward + retry or the full audited recovery above succeeds; after epoch commit, reverse epoch and legacy + reactivation are unsupported even before the first business v2 send. Keep admission FROZEN, + preserve backlog/schema/epoch/audit and forward-fix or run the guarded v2 resume. +- [ ] Abort and fence admission on any duplicate anomaly, unexpected indeterminate, backlog/SLO + breach, late-observation drop, stale legacy mutation, topic/security drift or readiness loss. + Never automatically resend while diagnosing. +- [ ] Emit a sanitized target rollout artifact: + + ```text + src/app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/deployment-cutover/manifest.json + ``` + + It binds target environment/cluster alias, source/artifact/release manifest digest, + target-binding-attestation digest, precondition evidence digest, watermark/manifest counts, + epoch/sentinel facts, final OPEN fence generation, ordinary post-resume append probe, + commands/timestamps, rollback zone, failures/skips and operator approvals. Payload, event hash, + credential and raw IDs remain excluded. The target runner writes identical bytes to the local + handoff and retained CI URI; both validate against the common build-evidence schema and + `deployment-rollout-manifest-v1.schema.json`. +- [ ] After commit, sentinel proof and artifact handoff, run + `verifyMessagingTargetBinding verifyMessagingDeploymentCutover` GREEN. Expected: binding and + deployment schemas PASS, exact target/source/artifact/release/attestation digest match, + approval/epoch/sentinel scenario IDs present exactly once, failed=0 and skipped=0. +- [ ] Acceptance claim: the exact target uses `POLLING_V2` and the sentinel/reference path is + healthy, and durable write admission is OPEN at the recorded post-cutover generation. Legacy + source/runtime cleanup is still pending the observation/rollback window. + +**Rollback checkpoint:** use only the two rehearsed zones. Schema is forward-only; dual relay +authority and destructive downgrade are forbidden. + +### Task 26: Complete observation, remove legacy runtime and requalify the cleanup artifact + +**Owner:** all affected leaves + repository-wide verification/Wiki +**Depends on:** Task 25, reviewed observation window, human cleanup approval + +**Files — delete:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessageBroker.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSender.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaMessageBroker.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFence.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFenceTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelayControlPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelaySnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionEvidence.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAuthorityCutoverPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxPreCommitRecoveryPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCaseTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEventStatus.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxCutoverPreconditionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperWiringTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLeaderElectionToken.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapter.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverJobSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunner.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunnerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyToV2CutoverContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfMessagingBrokerConfigured.java` + +**Files — create:** + +- `src/config/messaging/legacy-runtime-denylist.txt` +- `src/config/messaging/legacy-runtime-allowlist.txt` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingLegacyRuntimeDenylistTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingLegacyActivationRunbookContractTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingWriteAdmissionRecoverySettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingWriteAdmissionRecoveryApplicationRunner.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/MessagingWriteAdmissionRecoveryApplicationRunnerTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfig.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2SentinelContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/OutboundWithoutPermissionUseCase.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/test/resources/application-test.yml` +- `src/sample-portfolio/src/main/resources/application.yml` +- `src/sample-portfolio/src/test/resources/application-test.yml` +- `src/.env` +- `src/config/messaging/readiness-cards.yaml` +- `src/config/messaging/profile-compatibility.yaml` +- `src/config/messaging/release-profile-assertions.yaml` +- `docs/registries/env-keys.yaml` +- `docs/registries/capabilities.yaml` +- `docs/registries/error-codes.yaml` +- `docs/registries/metrics.yaml` +- `docs/registries/secrets-classification.yaml` +- `docs/runbooks/outbox-publish-failed.md` +- `docs/runbooks/outbox-dead-letter.md` +- `docs/runbooks/messaging-producer-unavailable-or-unauthorized.md` +- `docs/runbooks/messaging-outbox-backlog-and-stale-lease.md` +- `docs/runbooks/messaging-delivery-indeterminate-and-duplicate-burst.md` +- `docs/runbooks/messaging-schema-poison-or-record-too-large.md` +- `docs/runbooks/messaging-terminal-delivery-disposition.md` +- `docs/runbooks/messaging-topic-policy-or-partition-change.md` +- `docs/runbooks/messaging-shutdown-deploy-and-secret-rotation.md` +- `docs/runbooks/messaging-legacy-to-v2-relay-authority-cutover.md` +- `src/README.md` +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` +- `src/shared-contract/README.md` +- `src/shared-contract/CLAUDE.md` +- `src/adapter/outbound/messaging/README.md` +- `src/adapter/outbound/messaging/CLAUDE.md` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` +- `src/sample-portfolio/README.md` +- `src/sample-portfolio/CLAUDE.md` +- `src/app-bootstrap/build.gradle` +- `src/build.gradle` +- `docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md` +- `docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md` +- `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md` and only genuinely + derived raw documents + +- [ ] Observation exit requires the reviewed duration with duplicate anomaly 0, unexpected + indeterminate 0, late-drop 0, stable backlog/readiness, no legacy mutation and successful + disposition/rotation/shutdown drills. Durable write admission must remain OPEN at the recorded + post-cutover generation except for audited drills with successful guarded resume. Any breach + postpones cleanup. +- [ ] Write `MessagingLegacyRuntimeDenylistTest` first and run RED; it must find every exact + production symbol/config key above plus legacy reaper repository methods/beans. The denylist + contains exact FQCNs and keys, including: + + ```text + dev.caskeleton.adapter.outbound.messaging.core.MessageBroker + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaMessageBroker + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterSettings + dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher + dev.caskeleton.adapter.outbound.messaging.outbox.LegacyPublicationWriteFence + dev.caskeleton.adapter.outbound.messaging.outbox.OutboxEnvelopeJson + dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter + dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter + dev.caskeleton.application.outbox.OutboxMessagePublishPort + dev.caskeleton.application.outbox.LegacyOutboxRelayControlPort + dev.caskeleton.application.outbox.LegacyOutboxRelaySnapshot + dev.caskeleton.application.outbox.OutboxCutoverPreconditionEvidence + dev.caskeleton.application.outbox.OutboxCutoverPreconditionPort + dev.caskeleton.application.outbox.FinalizeOutboxAuthorityCutoverCommand + dev.caskeleton.application.outbox.FinalizeOutboxAuthorityCutoverResult + dev.caskeleton.application.outbox.OutboxAuthorityCutoverPort + dev.caskeleton.application.outbox.FinalizeOutboxAuthorityCutoverUseCase + dev.caskeleton.application.outbox.OutboxPreCommitRecoveryPort + dev.caskeleton.application.outbox.RecoverLegacyOutboxAuthorityCommand + dev.caskeleton.application.outbox.RecoverLegacyOutboxAuthorityResult + dev.caskeleton.application.outbox.RecoverLegacyOutboxAuthorityUseCase + dev.caskeleton.application.outbox.OutboxStorePort + dev.caskeleton.application.outbox.OutboxEvent + dev.caskeleton.application.outbox.OutboxEventStatus + dev.caskeleton.application.outbox.OutboxRelayFailureReport + dev.caskeleton.application.outbox.OutboxRelayFailureReportPort + dev.caskeleton.application.outbox.OutboxRelayResult + dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand + dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxCutoverPreconditionAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxAuthorityCutoverAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxPreCommitRecoveryAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository + dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper + dev.caskeleton.bootstrap.outbox.OutboxLeaderElectionToken + dev.caskeleton.bootstrap.outbox.OutboxLegacyToV2CutoverCoordinator + dev.caskeleton.bootstrap.outbox.OutboxLegacyPreCommitRecoveryCoordinator + dev.caskeleton.bootstrap.outbox.LegacyOutboxRelayControlAdapter + dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverJobSettings + dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverApplicationRunner + OutboxEventJpaRepository.deletePublishedBefore + OutboxEventJpaRepository.countGroupedByStatus + OutboxEventJpaRepository.findOldestUnpublishedOccurredAtByEventType + OutboxClaimRepository.claimEligible + outboxLeaderElection + outboxReaper + app.messaging.broker + app.messaging.kafka.brokers + APP_MESSAGING_BROKER + APP_MESSAGING_KAFKA_BROKERS + ca-skeleton.outbox.relay-enabled + ca-skeleton.messaging.maintenance.operation + ca-skeleton.messaging.maintenance.operation-id + ca-skeleton.messaging.maintenance.approval-evidence-id + ca-skeleton.messaging.maintenance.expected-target-alias + ca-skeleton.messaging.maintenance.expected-source-digest + ca-skeleton.messaging.maintenance.expected-artifact-digest + ca-skeleton.messaging.maintenance.expected-epoch + ca-skeleton.messaging.maintenance.expected-fence-generation + ``` + + This is the complete mandatory production token/key set derived one-to-one from `Files — + delete` plus the legacy repository methods, bean names and configuration keys. The contract + test snapshots the exact eight-key Task 20 maintenance settings/registry set and asserts set + equality with these eight denylist keys before scanning source/config/runbook references. It + rejects a missing or extra maintenance key, a missing denylist entry, an unclassified deleted + production class, and any allowlist entry outside the exact sample/R0 list below. + + The allowlist contains only these repository-relative paths: + + ```text + src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java + src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java + src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapter.java + src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapterTest.java + src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java + src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java + ``` + + No directory wildcard or silently ignored unknown path is allowed. +- [ ] Run the denylist RED before deletion: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingLegacyRuntimeDenylistTest' --console=plain + ``` + + Expected non-zero with every still-present forbidden FQCN/key/bean reported. Missing scan + roots or a test skip is not an accepted RED. +- [ ] Delete the listed runtime/relay/reaper sources and obsolete config keys. Retain + `NewOutboxEvent`, `LegacyOutboxAppendPort` and a separately named sample-only + `LegacyOutboxAppendAdapter` only as the documented R0 fixture until standalone sample + activation. Canonical ACTIVE context must prove legacy append/store/publish/relay/reaper bean + count 0. Replace the deleted cutover runner with the disabled-by-default + `MessagingWriteAdmissionRecoveryApplicationRunner`, which accepts only + `resume-polling-v2-writes`, imports no legacy/cutover type, and uses + `ResumePollingV2WriteAdmissionUseCase` with expected FROZEN generation, ACTIVE + `POLLING_V2` epoch and fresh sentinel/readiness evidence. + + ```text + ca-skeleton.messaging.write-admission-recovery.operation + ca-skeleton.messaging.write-admission-recovery.operation-id + ca-skeleton.messaging.write-admission-recovery.approval-evidence-id + ca-skeleton.messaging.write-admission-recovery.expected-target-alias + ca-skeleton.messaging.write-admission-recovery.expected-epoch + ca-skeleton.messaging.write-admission-recovery.expected-fence-generation + ``` + + No operation/default means no runner resource. Unknown or legacy operation names fail before + mutation; replay/mismatch and resume-before-sentinel remain non-zero. +- [ ] Keep retained V3 DB columns and canonical compatibility projection until a later forward + schema cleanup. Do not drop columns or rewrite history here. +- [ ] Re-run the runtime denylist GREEN across production Java, build files, YAML/env and registries; + only the exact sample/R0 source allowlist may remain. Explicitly exclude design/spec/plan/Wiki + history from raw-symbol matching: historical documentation is evidence, not an activation + surface. Compile success alone is not bean/resource absence evidence. Run the same focused + command GREEN; expect forbidden runtime match 0, canonical legacy bean/resource count 0 and + every allowlisted sample/R0 reference classified exactly. +- [ ] Run `MessagingLegacyActivationRunbookContractTest` RED before cleanup and GREEN after cleanup. + It scans operational runbooks semantically for executable legacy activation keys, commands, + dual-relay instructions or rollback-to-legacy actions, while allowing clearly labelled + historical facts and “must remain disabled/forbidden” statements. It does not raw-match this + plan/spec/branch-note. + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingLegacyActivationRunbookContractTest' --console=plain + ``` +- [ ] Because cleanup changes source/artifact digest, freeze a new human candidate and rerun the + Task 21 and 22 qualification lanes plus the Task 24 focused/Messaging/repository commands and + release-profile aggregator against the cleanup artifact. Do not replay Task 24's pre-cutover + status wording: production is already `POLLING_V2`. Pre-cleanup evidence is stale and cannot + qualify the cleanup artifact. +- [ ] Before deploying the cleanup artifact, run: + + ```bash + cd src && ./gradlew verifyMessagingFinalR2Profile --console=plain + ``` + + Expected non-zero because the qualified cleanup release manifest and cleanup rollout artifact + do not yet form a matching final chain. +- [ ] Deploy the requalified cleanup artifact without changing the already-active `POLLING_V2` + epoch. Before deployment, rerun target topology/security/ACL probes for the cleanup source/ + artifact and emit: + + ```text + src/app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json + ci-artifact://messaging/{targetAlias}/{cleanupSourceDigest}/cleanup-target-binding-attestation/manifest.json + ``` + + Run `verifyMessagingCleanupTargetBinding` GREEN; it must bind the same target identity and + current secret generation to the cleanup release digest without overwriting the immutable + original Task 25 attestation. Then deploy and emit byte-identical sanitized evidence: + + ```text + src/app-bootstrap/build/messaging-evidence/cleanup-rollout/manifest.json + ci-artifact://messaging/{targetAlias}/{cleanupSourceDigest}/cleanup-rollout/manifest.json + ``` + + It binds the target alias, cleanup source/artifact digest, qualified cleanup release-manifest + digest, cleanup-target-attestation digest, original deployment-cutover manifest digest, + unchanged epoch, recorded OPEN write-admission generation, sentinel/backlog/readiness/ + legacy-bean-0 facts, approval, + commands/timestamps, failed=0 and skipped=0. Validate it against the common and + deployment-rollout schemas. +- [ ] Re-run `verifyMessagingDeploymentCutover`, `verifyMessagingCleanupTargetBinding` and + `verifyMessagingFinalR2Profile` GREEN: + + ```bash + cd src && ./gradlew verifyMessagingTargetBinding \ + verifyMessagingDeploymentCutover \ + verifyMessagingCleanupTargetBinding \ + verifyMessagingFinalR2Profile --console=plain + ``` + + The final task consumes these exact local inputs: + + ```text + src/build/reports/messaging/release-profile/manifest.json + src/app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json + src/app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json + src/app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json + src/app-bootstrap/build/messaging-evidence/cleanup-rollout/manifest.json + ``` + + If qualification/build cleanup removed a Task 25 local handoff, restore only the byte-identical + retained CI artifact to its exact path after verifying its recorded digest/signature and target + provenance. Never synthesize, edit or substitute a current artifact for the immutable original. + The final task writes: + + ```text + src/build/reports/messaging/final-r2-profile/manifest.json + ``` + + Expected: exact target/cleanup source/artifact/release digest chain, approval/epoch/sentinel/ + write-admission-OPEN/legacy-zero scenario IDs exactly once, failed=0, skipped=0 and schema + PASS. CI retains the + final bytes under the target/cleanup-source-qualified namespace. +- [ ] Run final focused, Messaging and repository gates exactly as Task 24 plus: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingLegacyRuntimeDenylistTest' \ + --tests '*MessagingWriteAdmissionRecoveryApplicationRunnerTest' \ + --tests '*MessagingDisabledZeroResourceContractTest' --console=plain + ``` + +- [ ] Update §0 and card status from the final R2 aggregate only. First R2 may be marked complete + only after `verifyMessagingFinalR2Profile` passes; P5/P6/P7 remain unchanged. +- [ ] Before final Wiki capture, read the configured vault's `AGENTS.md`, `CLAUDE.md` and relevant + `rules/`, `.agents/`, `.claude/`, `.codex/` instructions. Resolve the branch with + `git branch --show-current`; for this plan's current `main` branch the canonical target is: + + ```text + /home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md + ``` + + Record implementation, files, decisions, exact commands/results/failures, release and rollout + evidence, unsupported claims and remaining risks. Add/link derived raw documents only when + honestly produced; otherwise record “없음”. Canonical vault unavailability is an explicit + completion blocker. +- [ ] Final handoff lists changed files, behavior, exact verification counts/results, failed/not-run + lanes, release/rollout fingerprints, Wiki capture and follow-up risks. + +**Rollback checkpoint:** after cleanup, do not restore legacy code for events already sent by v2. +Pause admission, preserve schema/backlog/epoch/audit and forward-fix. + +--- + +## 8. Task dependency graph + +```text +1 -> 2 + | + 3 -> 4 -> 5 -> 6 + | + 7 -> 8 -> 9 -> 10 -> 11 -> 12 -> 13 + | + 14 -> 15 -> 16 -> 17 + | | + +-------> 18 + | + 19 -> 20 + | + 21 -> 22 -> 23 -> 24 + | + 25 -> 26 +``` + +Parallel work is allowed only at non-overlapping stable boundaries: + +- Task 4 shared/sample resource work may run in parallel after Task 3, but Task 5 starts only after + both resource owners are stable. +- Persistence Tasks 7–13 are sequential because they share migration/entity/repository/CAS surfaces. +- Task 18 may start after Task 12 while Tasks 15–17 proceed, but Task 19 waits for both. +- Disposable cutover rehearsal Task 20 is never parallelized with producer, persistence or + configuration changes. +- Real Kafka Task 21 and security topology setup for Task 22 may prepare in parallel only after the + final canonical artifact is frozen; their evidence aggregation remains ordered. +- Target cutover Task 25 is a serialized deployment operation after Task 24 qualification. Task 26 + cleanup starts only after the reviewed observation window and requires a newly qualified artifact. +- Shared-worktree Gradle invocations remain sequential even when source subtasks are delegated. + +## 9. Minimum completion matrix + +| Requirement | Proving task | +| --- | --- | +| approved truth/no ACK overclaim | 1 | +| closed first-tuple registry/no future switches | 2, 24 | +| framework-free typed event/SPI | 3 | +| generic envelope + sample-owned payload schema | 4 | +| closed catalog/destination/digest/key | 5 | +| Draft 2020-12 deterministic bytes/admission | 6 | +| forward-only immutable event/delivery/journal/epoch | 7 | +| same-transaction validated append | 8 | +| exhaustive outcome + one-record relay | 9 | +| JIT claim/token/unexpired-lease CAS | 10 | +| late observation DB commit before source ACK | 11 | +| audited requeue/hold/skip/compensate | 12 | +| legacy/v2 mutual exclusion | 13 | +| finite typed config/disabled 0 | 14 | +| actual ACK-aware Spring Kafka gateway | 15 | +| admission/late queue/generation lifecycle | 16 | +| topic/security attestation | 17 | +| authenticated internal disposition endpoint | 18 | +| composition/readiness/observability | 19 | +| atomic watermark/reconcile/epoch/sentinel cutover implementation + disposable rehearsal | 20 | +| real PostgreSQL + real Kafka fault evidence | 21 | +| TLS/SASL/ACL + multi-broker RF/min ISR | 22 | +| env/registries/runbooks | 23 | +| no-skip pre-cutover release artifact + independent review | 24 | +| exact-target approved deployment cutover evidence | 25 | +| observation exit + legacy cleanup + cleanup-artifact requalification + final Wiki | 26 | + +## 10. Follow-up plans after first R2 + +다음은 이 계획을 확장하는 checkbox가 아니라 별도 설계 승인과 실행 계획이다. + +1. **Inbound Kafka + inbox + DLT/replay (P5)** + - 20번째 `adapter:inbound:messaging-kafka` leaf registry migration; + - manual ACK after application commit; + - inbox/effect identity, bounded retry, DLT ACK, replay/audit. +2. **PostgreSQL Debezium CDC (P6)** + - external Connect/Debezium asset, publication/slot/offset/WAL; + - insert-only mapping, shadow, authority-exclusive cutover/rollback and retention proof. +3. **Optional cards (P7)** + - Avro/Protobuf registry, Kafka EOS, retry topic, compaction, object-storage claim check, + alternate broker, multi-cluster, module split; + - each requirement gets an independent card/compatibility/evidence plan. +4. **Live non-empty legacy database migration** + - collect actual row volume/state distribution, lock/replication budget, data classification, + maintenance window and rollback evidence; + - separately approve either `LIVE_ADDITIVE_BACKFILL_IN_PLACE.v1` or + `COPY_AND_CUTOVER_WITH_RECONCILIATION.v1`. + +## 11. Final non-negotiable assertions + +- `KafkaSender.send()` return is not broker ACK. +- Kafka future success plus metadata is ACK observation; it is not consumer processing. +- `acks=all` without RF/min ISR/unclean-election evidence is not durable topology evidence. +- Kafka producer idempotence does not remove ACK-to-DB, restart, late-ACK or operator-requeue + duplicates. +- `EXHAUSTED` does not mean definitely not delivered. +- late ACK observation never rewrites authoritative delivery state. +- timestamp/random event ID is not aggregate total order. +- `outbox_event` wire bytes are immutable authority; JSONB/re-serialization is not. +- polling and CDC may never be simultaneous production dispatch authorities. +- operator mutation requires auth, expected generation/version, no active claim and immutable audit. +- disabled and configured-but-not-ready are different states. +- fake/single-node/local evidence cannot be relabelled as production R2. +- implementation completion requires exact commands/results and LLM Wiki capture. +- agent never stages, commits, amends or pushes. diff --git a/docs/superpowers/plans/2026-07-28-notification-production-capability.md b/docs/superpowers/plans/2026-07-28-notification-production-capability.md new file mode 100644 index 0000000..80bb35c --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-notification-production-capability.md @@ -0,0 +1,4075 @@ +# Notification Production Capability Implementation Plan + +> **Execution workflow:** 구현 시 `superpowers:subagent-driven-development`, +> `superpowers:test-driven-development`, `superpowers:verification-before-completion`, +> `superpowers:requesting-code-review`를 순서에 맞게 사용한다. 현재 세션에는 해당 skill package가 +> 노출되지 않았으므로 이 문서는 저장소의 기존 Redis/Fileserver/HTTP Client 계획 형식과 동일한 +> RED/GREEN/verification 규칙을 수동으로 명시한다. + +- 작성일: 2026-07-28 +- 상태: proposed implementation plan, 구현 미착수 +- 설계 정본: + [Notification Production Capability Deep Design](../specs/2026-07-28-notification-production-capability-design.md) +- 비교한 실행 계획: + [Redis Foundation](2026-07-28-redis-production-capability-foundation.md), + [Redis Runtime](2026-07-28-redis-runtime-cache.md), + [HTTP Client Foundation](2026-07-28-httpclient-production-capability-foundation.md), + [HTTP Client Total Deadline](2026-07-28-httpclient-total-deadline.md), + [Fileserver Foundation](2026-07-28-fileserver-production-capability-foundation.md), + [Fileserver Durable Recovery](2026-07-28-fileserver-durable-recovery.md), + [Fileserver R2](2026-07-28-fileserver-r2-control-plane-provider-selection.md) + +Repository commit policy는 모든 플랫폼에서 `human-only`다. 이 계획에는 +`git add`, `git commit`, `git amend`, `git push` 단계가 없다. + +## 1. Goal + +provider-neutral application intent를 다음 두 실행 모드로 안전하게 처리하는 production +Notification capability를 구현한다. + +- `BEST_EFFORT_INLINE`: root business transaction의 physical commit 뒤 한정된 provider attempt를 + 수행하고 직교 outcome을 호출자에게 반환한다. +- `DURABLE_ASYNC`: business state와 같은 PostgreSQL transaction에서 한 logical recipient의 + intent와 frozen provider leg를 append하고, 별도 dispatcher가 claim, `WIRE_AUTHORIZED`, + provider call, terminal-once result, receipt/reconciliation을 수행한다. + +초기 qualification 대상은 다음 exact card 세 개뿐이다. + +```text +slack-web-api-inline-single-local-v1 +slack-web-api-durable-single-local-v1 +aws-ses-v2-durable-single-local-sns-v1 +``` + +Slack은 Web API `chat.postMessage`, email은 Amazon SES v2 `SendEmail`을 사용한다. SES feedback +topology는 `configuration set -> SNS HTTPS adapter-inbound-web -> DLQ`로 고정한다. + +이 계획은 외부 provider와 local DB 사이 exactly-once, inbox placement, read receipt 또는 +production topology R3를 주장하지 않는다. + +## 2. Architecture and fixed decisions + +```text +feature application policy + -> NotificationKindPolicy + -> BEST_EFFORT_INLINE + -> TransactionPort.inRootWrite(business write) + -> physical commit + -> InlineNotificationAttemptPort + -> DURABLE_ASYNC + -> TransactionPort.inWrite(business write + NotificationIntentAppendPort) + -> same PostgreSQL commit + +NotificationDispatchUseCase + -> short claim transaction + -> short ATTEMPT_RESERVED/WIRE_AUTHORIZED transaction + -> render + exactly one authorized provider call outside DB transaction + -> terminal-once result/frozen projection transaction + +SES SNS HTTPS callback + -> inbound signature/account/topic verification + -> NormalizedNotificationReceiptCommand + -> application receipt reducer + -> PostgreSQL receipt/orphan/suppression projection +``` + +고정 결정: + +1. source business state와 notification journal은 같은 PostgreSQL transaction manager에 + 참여한다. +2. intent 하나는 logical recipient 한 명이고 delivery row는 provider leg다. +3. mode와 admission class는 application `NotificationKindPolicy`만 결정한다. +4. config의 `expected-mode`는 assertion이며 mode override가 아니다. +5. provider call은 DB transaction 밖에서만 수행한다. +6. claim owner token과 immutable attempt execution token을 분리한다. +7. `WIRE_AUTHORIZED` commit을 local linearization point로 사용한다. +8. transmission certainty, retry disposition, fault scope를 한 enum으로 합치지 않는다. +9. binding/account fault는 PostgreSQL shared admission gate를 `PARKED`로 만들고 initial fallback을 + 자동 활성화하지 않는다. +10. PII payload는 `DIRECT_AEAD_AES_256_GCM_V1`, lookup/dedupe는 purpose-separated versioned + HMAC을 사용한다. +11. 기존 `slack-webhook`, `google-email`, raw `NotificationPort`는 R0 legacy path로 동결한 뒤 + route cutover가 증명되면 제거한다. +12. legacy→canonical 전환은 PostgreSQL fence, bounded permit, append-only operation/ + attestation sequence, retained signed-evidence header와 proof registry로 수행한다. +13. BEGIN은 독립 infrastructure issuer가 서명한 complete old-node inventory header와 node rows를 + server-side 검증·동결한다. `QUIESCENCE_REQUIRED` COMPLETE는 그 BEGIN에 귀속된 exact signed + per-node irreversible deployment-generation tombstone, legacy credential-or-egress revocation, + ACTIVE permit 0과 provider-call-ledger open-count 0의 durable proof를 요구한다. 이 사실은 + monotonic/irreversible하므로 application pre-commit freshness나 DEFERRABLE trigger를 + COMPLETE authority로 사용하지 않는다. +14. cutover causality는 post-lock shared DB sequence와 explicit BEGIN FK가 SSOT다. timestamp는 + post-lock `clock_timestamp()` 보조 evidence이고 transaction-start 시각은 사용하지 않는다. +15. V8은 complete canonical upgrade history를 검증·보존하고 discriminator를 + `UPGRADE_VALIDATED`+validated-history digest로 닫으며, exact empty database는 + `AWAITING_SIGNED_FRESH_PROVISIONING`+두 arm field null로 남긴다. fresh canonical seed는 + migration 안에서 만들지 않는다. 별도 final-artifact `notificationFreshProvisioning` + Gradle/CLI가 independent infrastructure issuer의 signed DB-birth certificate와 이미 + committed irreversible no-legacy-authority fence를 Java로 검증한 뒤, exact two-method/ + two-function PostgreSQL provisioning port를 통해 provenance, + `INITIALIZE_CANONICAL_FRESH`, 모든 canonical fence와 `FRESH_PROVISIONED`+fresh token을 한 + transaction으로 생성한다. runtime은 이 commit과 retained Java 재검증 전까지 dark다. +16. 정확히 세 role만 둔다. `notification_migrator`는 Flyway/schema owner, + `notification_runtime`은 non-owner runtime role, `notification_provisioner`는 fresh + provisioning exact two-function operation 전용 role이며 그 밖의 제4 notification role은 + 만들지 않는다. raw + database credential은 각 전용 reference로만 해석하고 production artifact에는 issuer private + key를 넣지 않는다. + +## 3. Scope boundary and owner leaves + +정확한 leaf와 production dependency edge는 +`src/config/architecture/modules.json`에서 파생한다. 이 계획은 registry edge를 추가하지 않는다. + +| 책임 | owner leaf | Gradle path | 기존 허용 edge | +| --- | --- | --- | --- | +| semantic values, policy, port, use case | `application-core` | `:application-core` | `domain-core`, `shared-contract` | +| provider catalog/render/SPI/Slack/SES | `adapter-outbound-notification` | `:adapter:outbound:notification` | `domain-core`, `application-core`, `shared-contract`, `adapter-outbound-support` | +| transaction/crypto/schema/store/claim | `adapter-outbound-persistence-jpa` | `:adapter:outbound:persistence-jpa` | `domain-core`, `application-core`, `shared-contract` | +| SNS HTTPS verification/transport mapping | `adapter-inbound-web` | `:adapter:inbound:web` | `domain-core`, `application-core`, `shared-contract` | +| canonical graph/composition/scheduler/readiness | `app-bootstrap` | `:app-bootstrap` | registry에 등록된 runtime leaves | + +금지: + +- `domain-core`에 notification framework/transport/persistence 개념을 추가하지 않는다. +- notification leaf가 persistence, inbound-web, httpclient sibling leaf를 의존하지 않는다. +- inbound-web가 notification outbound adapter 타입을 import하지 않는다. +- app-bootstrap settings/configuration에 mode, retry, fallback, consent 같은 정책을 구현하지 + 않는다. +- sample WorkLog를 production Notification consumer로 만들지 않는다. + +## 4. Evidence ladder and claim rule + +| evidence | 허용되는 주장 | +| --- | --- | +| application unit/contract | framework-free semantic/state policy가 정의됨 | +| adapter fake/loopback protocol | local render와 provider request/outcome mapping이 정의됨 | +| real PostgreSQL concurrency/fault | same-DB append와 provider-neutral durable protocol의 local evidence | +| Slack sandbox | exact Slack card의 provider evidence | +| SES sandbox + actual SNS callback | exact SES/SNS card의 provider/feedback evidence | +| privacy/load/rotation/rollout drill | selected card의 operational R2 | + +낮은 row의 evidence를 높은 row나 다른 provider/account/region/workspace/mode로 일반화하지 않는다. +실 provider lane이 실행되지 않으면 코드는 구현될 수 있어도 해당 exact card는 +`NOT_QUALIFIED`다. + +## 5. Execution rules + +1. 모든 checkbox는 구현 시작 시 `[ ]`에서 시작한다. +2. 각 behavior task는 먼저 명시한 test를 작성하고 같은 focused command로 RED와 GREEN을 + 확인한다. +3. RED가 예상 원인이 아니라 compilation drift, 외부 환경 또는 unrelated dirty change로 + 실패하면 구현하지 말고 원인을 먼저 분리한다. +4. RED가 처음부터 통과하면 기존 coverage 또는 plan drift를 조사하고 test를 강화한다. +5. ordinary `test`/`check`에는 실제 network, credential, account 또는 skip 기반 성공을 넣지 + 않는다. +6. task가 끝날 때 focused test, owner leaf test/check, 그 task가 건드린 boundary gate 순으로 + 검증한다. +7. migration은 expand-first다. 새 worker와 provider는 canonical binding 전까지 dark/disabled다. +8. canonical disabled + legacy absent인 `PURE_DISABLED`에서 provider client, thread, scheduler, + probe, callback subscription, operator와 application/runtime table DML·scan은 0이어야 한다. + PRE legacy-only bridge는 별도 closed state다. expand-first V7 DDL, V8 structural + validation과 explicitly invoked final `notificationFreshProvisioning`은 runtime + zero-resource 계수에서 제외한다. exact empty FINAL startup은 + `AWAITING_SIGNED_FRESH_PROVISIONING`으로 liveness만 유지하고 provider/worker/admission/DML은 + 0이다. +9. rollback 시 accepted/indeterminate intent를 legacy path로 자동 resend하지 않는다. +10. active/retained row가 참조하는 template, binding, renderer, AEAD/HMAC revision을 제거하지 + 않는다. +11. shared workspace의 기존 Fileserver/JPA/Object Storage 및 file-publication 변경을 덮어쓰지 + 않는다. 각 task 시작 전 `git status --short`로 overlap을 다시 확인한다. +12. 새 타입이나 파일이 이 계획의 surface 밖에 필요하면 조용히 확장하지 말고 plan을 먼저 + 갱신한다. +13. 각 Wave exit에서 LLM Wiki branch-note를 갱신해 files, decisions, commands/results, + evidence grade와 blocker를 남긴다. 파생 raw 문서가 없으면 cluster에 “없음”을 명시한다. + +## 6. Stop conditions + +다음 중 하나라도 확인되면 해당 wave를 중단하고 설계/계획을 수정한다. + +- business DB와 notification journal이 같은 transaction manager에 참여하지 못한다. +- `TransactionPort.inRootWrite`가 ambient actual transaction을 side effect 전에 거부하거나 + physical commit-before-return을 보장하지 못한다. +- Slack/AWS SDK의 hidden retry를 끄거나 실제 physical attempt를 journal에 계수할 수 없다. +- SES configuration set, SNS TopicArn, HTTPS ingress, DLQ topology를 exact profile로 묶을 수 없다. +- provider callback signature 검증에 outbound notification adapter 의존이 필요하다. +- migration `V7`이 실행 시점에 이미 다른 의미로 사용 중이다. +- canonical/legacy activation을 동시에 허용해야만 rollout이 가능하다. +- independent issuer가 complete old-node inventory와 per-node irreversible + deployment/credential/egress fence를 서명·검증할 수 없다. +- BEGIN inventory 또는 quiescence evidence의 canonical signed payload, signature, issuer/trust + snapshot, issued/expires/verified time, environment/DB/artifact, consumer inventory identity, + provider-call-ledger identity/snapshot과 zero-node authority를 retained header로 보존하고 Java + write/startup에서 재검증할 수 없다. +- COMPLETE 뒤 paused old node나 revoked legacy credential/egress identity가 provider I/O 0임을 + 증명하지 못한다. +- V8이 exact empty를 `AWAITING_SIGNED_FRESH_PROVISIONING`으로 분류하거나 nonempty + missing-fence database를 mutation 0으로 거부하지 못한다. +- independent infrastructure issuer가 committed irreversible no-legacy-authority fence를 먼저 + 확인한 signed DB-birth authorization과 final-artifact `notificationFreshProvisioning` + one-transaction/two-function protocol을 제공하지 못한다. +- `notification_migrator`/`notification_runtime`/`notification_provisioner` role, safe + `SECURITY DEFINER` ownership/grant closure 또는 FINAL DML/sequence/execute revoke를 증명하지 + 못한다. +- real-provider evidence가 없는데 R2/production-ready 표현이 필요하다. + +--- + +## Wave A — Truth freeze and application foundation + +### Task 1: Freeze current R0 truth and protect unrelated changes + +**Owner leaf:** documentation + existing notification/bootstrap tests +**Depends on:** approved deep design +**Behavior change:** none + +**Files:** + +- Modify: + `src/adapter/outbound/notification/README.md` +- Modify: + `src/adapter/outbound/notification/CLAUDE.md` +- Test: + `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java` +- Test: + `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` + +- [x] Record the starting `git status --short`, current branch, registry edges and current provider + dependencies in the branch-note. +- [x] Re-run the existing R0 behavior without changing it: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OptionalAdapterBeanGatingTest' --console=plain + ``` + +- [x] Document one truth table covering raw `NotificationPort`, fan-out routing, global fail-open, + fake-only `google-email`/`slack-webhook`, selector drift and production consumer count 0. +- [x] Mark all current provider seams `R0 legacy`; do not call them Slack/Email integration. +- [x] Preserve a deletion inventory for Wave G rather than adding behavior to legacy classes. +- [x] Acceptance: the baseline is reproducible, no source behavior changes, no unrelated dirty file + changes. + +**Rollback checkpoint:** documentation-only changes may be reverted independently; legacy tests remain +the executable baseline until the canonical-only cutover and deletion in Task 21. + +### Task 2: Add the physical root-write transaction contract + +**Owner leaves:** `application-core` (`:application-core`), then +`adapter-outbound-persistence-jpa` (`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 1 + +**Files:** + +- Create: + `src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java` +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java` +- Modify: + `src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java` +- Create: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java` +- Modify only as mechanical interface implementers: + `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java` +- Modify: + `src/application-core/README.md`, + `src/application-core/CLAUDE.md`, + `src/adapter/outbound/persistence-jpa/README.md`, + `src/adapter/outbound/persistence-jpa/CLAUDE.md` + +- [x] Write RED tests proving: + `inRootWrite` is part of the framework-free contract; ambient actual transaction is rejected before + action/TM side effects; root execution is `WRITE + REQUIRED + READ_COMMITTED`; return occurs after + commit; commit failure propagates and no caller-visible committed result is produced. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*TransactionPortTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*SpringTransactionPortTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*CleanArchitectureTest' \ + --tests '*ArchitectureViolationFixtureTest' \ + --console=plain + ``` + + Expected failure: `inRootWrite`/typed rejection and adapter behavior do not exist. + +- [x] Add an abstract `inRootWrite` contract and update all current fake implementations explicitly; + do not provide a default that silently delegates to join-capable `inWrite`. +- [x] Implement the adapter precondition with actual transaction state inspection before + `TransactionTemplate.execute`. +- [x] Reuse a prebuilt `WRITE + REQUIRED + READ_COMMITTED` template. Do not add `NEVER` propagation or + a new `TransactionMode`. +- [x] Extend the transaction fitness rule so a `WRITE_REPOSITORY + WRITE` use case may directly call + either join-capable `inWrite` or root-only `inRootWrite`, while READ/REQUIRES_NEW mappings stay + unchanged. Add positive and negative fixtures so this is not a broad transaction bypass. +- [x] Verify GREEN with the same three commands. +- [x] Verify the architecture RED/GREEN with the third command too; the violation fixture must fail + for a declared WRITE boundary that calls neither `inWrite` nor `inRootWrite`, while the positive + root-write fixture passes. +- [x] Run compatibility regression: + + ```bash + cd src && ./gradlew :application-core:test \ + :adapter:outbound:persistence-jpa:test \ + :sample-portfolio:test --console=plain + ``` + +- [x] Acceptance: nested use fails before action/provider call, root return is post-commit, existing + `inWrite`/`inRead`/`inNew` semantics are unchanged. + +**Rollback checkpoint:** this public contract cannot be rolled back after Task 4 callers use it. +Before that point, revert the interface and all mechanical fake changes together. + +### Task 3: Introduce bounded application notification values and policy + +**Owner leaf:** `application-core` (`:application-core`) +**Depends on:** Task 2 + +**Files — create under** +`src/application-core/src/main/java/dev/caskeleton/application/notification/`: + +- `NotificationChannel.java` +- `NotificationIntentId.java` +- `NotificationDeliveryId.java` +- `NotificationAttemptId.java` +- `NotificationReceiptEventId.java` +- `NotificationKindId.java` +- `NotificationRouteId.java` +- `NotificationTemplateRef.java` +- `NotificationMode.java` +- `NotificationAdmissionClass.java` +- `NotificationRouteStrategy.java` +- `ConsentCheckMode.java` +- `NotificationRecipientReference.java` +- `EmailRecipientReference.java` +- `SlackAudienceReference.java` +- `NotificationTemplateValue.java` +- `NotificationTemplateParameters.java` +- `NotificationKindPolicy.java` +- `NotificationFrozenPlan.java` +- `NotificationIntentDraft.java` +- `SubmissionCertainty.java` +- `RetryDisposition.java` +- `NotificationFaultScope.java` +- `NotificationReasonCode.java` +- `ProviderAttemptOutcome.java` +- `TargetAttemptOutcome.java` +- `NotificationRequestResult.java` + +**Tests — create:** + +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java` + +- [x] Write RED tests for bounded/nonblank IDs, one-recipient typing, closed template scalar types, + locale/time bounds, immutable collections, redacted `toString`, and exact orthogonal outcome + axes. +- [x] Write RED policy tests proving mode/admission are code-owned, config cannot strengthen or + weaken them, and a critical kind cannot bind `BEST_EFFORT_INLINE`. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*NotificationValueContractTest' \ + --tests '*NotificationKindPolicyTest' \ + --tests '*NotificationRequestResultTest' \ + --console=plain + ``` + + Expected failure: the new semantic types and invariants do not exist. + +- [x] Implement only Java 21/framework-free records, sealed interfaces and immutable collections. +- [x] Do not add provider IDs, AWS/Slack types, raw JSON, `Map`, inbound DTOs, raw HTML + or arbitrary address/channel union types. +- [x] Keep feature-specific factory examples in test fixtures; do not add WorkLog or password-reset + business concepts to production packages. +- [x] Verify GREEN with the same command and then: + + ```bash + cd src && ./gradlew :application-core:test --console=plain + ``` + +- [x] Acceptance claim: application semantic contract R1 only; no provider or durable evidence yet. + +**Rollback checkpoint:** no external side effects/schema. Revert this whole value cluster before ports +in Task 4 depend on it. + +### Task 4: Add application ports, dispatcher and receipt reducer contracts + +**Owner leaf:** `application-core` (`:application-core`) +**Depends on:** Task 3 + +**Files — create under** +`src/application-core/src/main/java/dev/caskeleton/application/notification/`: + +- `InlineNotificationAttemptPort.java` +- `NotificationIntentAppendPort.java` +- `NotificationPlanPort.java` +- `NotificationPlanningResult.java` +- `NotificationAppendResult.java` +- `NotificationDeliveryStorePort.java` +- `NotificationProviderAttemptPort.java` +- `NotificationTechnicalSuppressionPort.java` +- `NotificationReceiptStorePort.java` +- `NotificationMaintenanceStorePort.java` +- `NotificationReconciliationPort.java` +- `NotificationAdmissionReadinessPort.java` +- `NotificationCanonicalWriterFencePort.java` +- `NotificationCanonicalWriterRouteSet.java` +- `NotificationWriterRouteSet.java` +- `NotificationWriterCutoverPort.java` +- `NotificationWriterQuiescenceAttestationPort.java` +- `NotificationWriterInventoryEvidenceVerifierPort.java` +- `NotificationWriterInventoryEvidence.java` +- `NotificationSignedEvidenceHeader.java` +- `NotificationEvidenceTrustSnapshot.java` +- `SignedNotificationWriterInventoryManifest.java` +- `SignedNotificationWriterQuiescenceManifest.java` +- `InitializeNotificationWriterFencesCommand.java` +- `InitializeNotificationWriterFencesResult.java` +- `InitializeNotificationWriterFencesOperation.java` +- `InitializeNotificationWriterFencesUseCase.java` +- `NotificationCanonicalWriterFenceGuard.java` +- `NotificationLegacyWriterPermitCommand.java` +- `NotificationLegacyWriterPermitResult.java` +- `NotificationLegacyWriterPermitUseCase.java` +- `TerminalizeExpiredNotificationWriterPermitsCommand.java` +- `TerminalizeExpiredNotificationWriterPermitsResult.java` +- `TerminalizeExpiredNotificationWriterPermitsOperation.java` +- `TerminalizeExpiredNotificationWriterPermitsUseCase.java` +- `RecordNotificationWriterQuiescenceAttestationCommand.java` +- `RecordNotificationWriterQuiescenceAttestationResult.java` +- `RecordNotificationWriterQuiescenceAttestationOperation.java` +- `RecordNotificationWriterQuiescenceAttestationUseCase.java` +- `SwitchNotificationWriterOwnershipCommand.java` +- `SwitchNotificationWriterOwnershipResult.java` +- `SwitchNotificationWriterOwnershipOperation.java` +- `SwitchNotificationWriterOwnershipUseCase.java` +- `NotificationWriterOwnership.java` +- `NotificationDispatchCommand.java` +- `NotificationDispatchResult.java` +- `NotificationDispatchUseCase.java` +- `NormalizedNotificationReceiptCommand.java` +- `NotificationReceiptFact.java` +- `NotificationReceiptProjection.java` +- `ApplyNotificationReceiptCommand.java` +- `ApplyNotificationReceiptResult.java` +- `ApplyNotificationReceiptUseCase.java` +- `NotificationAdmissionGateCommand.java` +- `NotificationAdmissionGateUseCase.java` +- `ReconcileNotificationDeliveriesCommand.java` +- `ReconcileNotificationDeliveriesResult.java` +- `ReconcileNotificationDeliveriesUseCase.java` +- `NotificationMaintenanceCommand.java` +- `NotificationMaintenanceResult.java` +- `NotificationMaintenanceUseCase.java` +- `NotificationProviderCapabilityDescriptor.java` +- `NotificationStoreCapabilityDescriptor.java` +- `NotificationReceiptIngressCapabilityDescriptor.java` +- `NotificationCapabilityCompatibilityValidator.java` +- `NotificationOperationsSnapshotPort.java` +- `NotificationOperationsSnapshot.java` +- `NotificationOperationsSnapshotQuery.java` +- `NotificationOperationsSnapshotUseCase.java` +- `NotificationApplicationException.java` + +**Tests — create:** + +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java` + +- [x] Execute Task 4 as five sequential RED/GREEN subcycles, never as one large implementation: + (1) port/planning boundary, (2) dispatch state machine, (3) receipt reducer, + (4) admission/reconciliation/maintenance, and (5) compatibility descriptors/validator. + Run only the named focused test(s) for a subcycle before starting the next, then run the + combined command below. +- [x] Write RED port tests proving append joins caller transaction semantics, provider SDK/entity/DTO + types are absent, and no giant send/store/receipt port is introduced. +- [x] Write RED planning handoff tests: + feature factory + `NotificationKindPolicy` produce a draft; `NotificationPlanPort` returns only + application-owned `NotificationFrozenPlan`; append and inline ports consume that frozen plan; + adapter compiled binding/profile types never cross into application or persistence. +- [x] Write RED dispatcher tests for the sequence: + short claim transaction -> short reserve/authorize transaction -> provider outside transaction + -> terminal-once result transaction. +- [x] Cover stale claim token, distinct attempt execution token, late exact result, fallback only on + `DEFINITELY_NOT_APPLIED`, and terminal indeterminate without blind retry. +- [ ] Cover `PARK_BINDING`: gate CAS by scope/generation, parked leg not hot-looping, audited resume + rechecking expiry/cancel/suppression and not activating initial fallback. +- [ ] Cover a route-specific single-writer fence with an exact database generation and owner + (`LEGACY` or `CANONICAL`). Both legacy admission and canonical intent admission must present the + expected generation; stale/mismatched ownership fails closed before append or provider I/O. + Split the transaction contracts: `NotificationCanonicalWriterFenceGuard` asserts canonical + ownership inside the caller's business-write/intent-append transaction and holds a tested + share lock that conflicts with `BEGIN_DRAIN` until physical commit/rollback; + `NotificationLegacyWriterPermitUseCase` physically commits a bounded acquire before provider + I/O, returns DB-time acquired/wire-deadline/expiry facts, and releases afterward. The wrapper/ + client refuses network start after the committed absolute wire deadline and enforces the + smaller of its monotonic elapsed budget and DB interval; + `TerminalizeExpiredNotificationWriterPermitsUseCase` is a distinct PRE-only authenticated + root-write operation that, only during exact DRAINING, terminalizes bounded DB-time-expired + ACTIVE permits across all generations with registry-bound token/rowVersion CAS; + `SwitchNotificationWriterOwnershipUseCase` physically commits the + audited `BEGIN_DRAIN`, `COMPLETE_SWITCH` or `ABORT_DRAIN` CAS. `BEGIN_DRAIN` verifies an + independently signed, short-lived exact environment/DB/route/PRE-artifact complete + old-writer inventory through `NotificationWriterInventoryEvidenceVerifierPort`, then + atomically freezes its canonical node row set/count/digest while closing new legacy acquire; + caller-authored node digests have no authority. The operations query polls active count/max + expiry outside a transaction; + `COMPLETE_SWITCH` refuses unsafe legacy permits. Freeze the only transition matrix: + `ACTIVE/LEGACY@g -> DRAINING/LEGACY@g` (BEGIN), + unchanged DRAINING (terminalize), + `DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1` (COMPLETE), or + `DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1` (ABORT). Requests never supply a target owner; + every action from CANONICAL and every reverse transition fails with mutation 0. Crashed permits become + `EXPIRED_PROVEN` only for a transport profile with tested hard bounds; current R0 timeouts + become `TIMED_OUT_UNPROVEN`. For an unproven profile, an authenticated, immutable, fresh + route/generation quiescence attestation is mandatory even when the unproven set is empty. + Its independently signed manifest must exactly match the complete BEGIN node inventory and bind + per-node retired/quiesced facts plus deployment-generation tombstones and legacy + credential/egress revocation that make resume impossible, production consumer inventory/count 0 and provider-call ledger + identity/open-count 0. ACTIVE permit 0 remains mandatory for every profile and cannot be + overridden by attestation. It includes the bounded canonical + digest of every generation/profile `TIMED_OUT_UNPROVEN` + `(token,generation,profile,state,rowVersion)` permit tuple, the distinct permit-holder set and + the persisted transport-proof registry; COMPLETE locks/recomputes the exact sets and records + its token/digest. Every permit holder must be in the BEGIN inventory. The route-level + proof requirement is `QUIESCENCE_REQUIRED` when any current/retiring persisted registry + profile is `QUIESCENCE_REQUIRED`; only an all-`HARD_BOUND_PROVEN` registry may use the + hard-bound path. Every switch + operation carries authenticated actor, reason and reviewed target + generation. For every COMPLETE arm, application validation alone is insufficient: the + persistence port must lock the retained BEGIN inventory header/children and pass its canonical + payload/signature/SPKI/trust snapshot back through the Java verifier before the ownership + mutation. It rejects a missing, altered, unverifiable or semantically mismatched BEGIN even + when the permit/registry rows are structurally valid. QUIESCENCE_REQUIRED additionally locks + and revalidates the committed signed quiescence header, exact per-node + tombstone/revocation rows, ACTIVE permit 0 and provider-ledger open-count 0. + HARD_BOUND_PROVEN forbids a quiescence header/children but still requires the verified signed + BEGIN plus the exact all-hard-bound registry/evidence revision, safe terminal permits and + ACTIVE permit 0. The ownership transaction returns only after physical commit. Manifest + expiry gates admission into an immutable evidence header; once verified and committed, the + signed deployment/credential/egress fences are durable monotonic facts and are not converted + into a pre-commit TTL guard. No use case sleeps or holds a DB transaction while waiting. + The route's exact transport profile/proof/evidence registry comes from + `NotificationWriterRouteSet`, not permit rows or request data; therefore a + `QUIESCENCE_REQUIRED` route with permit count 0 still requires attestation. +- [ ] Cover audited batch `InitializeNotificationWriterFencesUseCase`: it rejects ambient + transactions and root-commits the bounded ordered reviewed route set as + `ACTIVE/LEGACY@predecessor` only when the command set/digest exactly equals + `NotificationWriterRouteSet` derived from the compiled cutover route catalog and every + notification control/data-plane journal table is empty. It inserts all + fences, one immutable operation header, all route-result children and the exact route/profile/ + admission-role/proof-class/evidence-revision registry snapshot atomically; partial or + sequential route initialization is forbidden. Initialization and every later switch append + immutable operation history in the same root transaction as fence mutation. Replay of any old + same-token/same-input route set returns its stored committed result; token reuse with a + different route set/action/input and existing/mismatched/nonempty state fail without mutation. + A mutable `last_operation_token` fence field is never the audit or replay SSOT. +- [ ] Cover `TerminalizeExpiredNotificationWriterPermitsUseCase`: it is not a scheduler, snapshot + query or COMPLETE side effect. An authenticated PRE operator supplies exact route/drain + generation, bounded batch (`<=100`), reason and opaque operation token. The root transaction + locks `DRAINING/LEGACY`, the immutable persisted registry and DB-time-expired ACTIVE permits + across all historical generations in canonical order. It CASes exact token/rowVersion to + `EXPIRED_PROVEN` only for HARD_BOUND_PROVEN or `TIMED_OUT_UNPROVEN` only for + QUIESCENCE_REQUIRED, then appends affected count/set digest and actor/reason in the operation + journal before commit. Same-token/same-input replay returns the stored result; changed input, + non-DRAINING fence, unknown/drifted profile, nonexpired row or commit failure changes nothing. + Idempotency lookup precedes set selection, so replay still returns the original affected result + after those rows are terminal. The affected set is a derived result, not caller input, and its + digest covers the sorted immutable post-CAS tuple. The operation journal persists requested + batch bound and a server-canonical request-input digest so changed-input token reuse fails. It + performs provider I/O 0 and never updates the fence's latest-mutation pointer. +- [ ] Cover `RecordNotificationWriterQuiescenceAttestationUseCase`: only a method-security operator + path may root-commit an immutable exact route/draining-generation/transport-profile + set attestation after BEGIN_DRAIN. It accepts a bounded signed quiescence manifest, verifies it + through the trusted issuer-key port, and server-derives the bounded sorted blocking permit + `(token,generation,profile,state,rowVersion)` set/count/digest, distinct holder set, frozen BEGIN + old-node set, consumer inventory/count 0 and provider-call-ledger identity/open-count 0. Exact + node-set equality, holder subset, per-node retired/quiesced + restart/credential/egress + revocation facts, environment/DB/artifact/ + generation identity and bounded freshness are mandatory; caller-provided node/zero-fact digest + is never authoritative. Same-token/same-input replay is idempotent and mismatch is rejected. + `COMPLETE_SWITCH` for an unproven transport requires the attestation token and fails on + missing/stale/wrong-route/wrong-generation, partial multi-profile/node coverage, omitted/extra + node or permit holder, changed permit-set digest, registry mismatch, unsigned/unknown-issuer + evidence or nonzero facts. After initialization, proof data comes from the immutable persisted + registry/BEGIN inventory; caller data and permit rows cannot invent it. + Both BEGIN and attestation persist a first-class immutable signed-evidence header containing + exact canonical payload bytes/profile, signature bytes/digest, algorithm, issuer key ID, + bounded canonical issuer public-key SPKI plus its digest and trust-catalog + revision/historical-key-status snapshot, issued/expires/verified DB times, + profile-pinned `allowedClockSkew` and `acceptanceMargin`, + environment/DB/artifact identity, consumer-inventory identity, provider-call-ledger identity/ + snapshot and canonical child count/set digest. A zero-node manifest still creates one header, + so issuer authority is never hidden in absent child rows. Java write validation and canonical + startup reverify stored payload/signature/SPKI, header/child exact equality and that the + historical key digest remains allowed/non-revoked in the current closed catalog. Admission + requires + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`; after + acceptance, expiry does not reverse the recorded irreversible facts. SQL owns only structural + FK/digest/state/window-shape constraints and never claims Ed25519 verification. +- [ ] Cover the stale-node safety proof that closes the paused-node race: pause an old bridge node + after its last permitted local step, commit BEGIN, signed per-node deployment-generation + tombstone plus legacy credential/egress revocation, ACTIVE permit 0, provider-ledger 0 and + COMPLETE, then resume that exact process. Its legacy client must fail before provider network + I/O, the revoked credential/egress identity must record provider-call count 0, and canonical + ownership must remain the only admitted writer. Repeat for a cached credential and an already + constructed client/connection. If this cannot be proven, keep the route DRAINING and + `NOT_QUALIFIED`. +- [x] Cover periodic reconciliation as a separate use case: + bounded claim transaction -> provider reconciliation outside transaction -> token/version + guarded result transaction; orphan attach without provider I/O stays in the bounded store + transaction. A scheduler must not call store/provider ports itself. +- [x] Cover receipt fact permutations so `SEND/DELIVERY/BOUNCE/COMPLAINT/DELIVERY_DELAY` produce the + same orthogonal projection independent of order; accepted fact is never erased. +- [x] Keep technical-suppression policy in the receipt reducer/application use case: hard bounce and + complaint may emit an explicit suppression mutation; transient/delayed/soft bounce does not. + Business consent/unsubscribe remains outside this capability. +- [x] Cover the pure compatibility validator: application policy mode/admission, provider/store/ + ingress descriptors, exact receipt requirement and frozen revision availability. Concrete + adapter settings/types must not enter the validator. +- [x] Cover the operational read boundary: bootstrap never calls + `NotificationOperationsSnapshotPort` directly. A concrete + `NotificationOperationsSnapshotUseCase implements + QueryUseCase` invokes + the port only inside `TransactionPort.inRead` and returns bounded, non-sensitive values. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*NotificationPortBoundaryTest' \ + --tests '*NotificationPlanningBoundaryTest' \ + --tests '*NotificationDispatchUseCaseTest' \ + --tests '*NotificationReceiptReducerTest' \ + --tests '*NotificationAdmissionGateUseCaseTest' \ + --tests '*NotificationCanonicalWriterFenceGuardTest' \ + --tests '*InitializeNotificationWriterFencesUseCaseTest' \ + --tests '*NotificationLegacyWriterPermitUseCaseTest' \ + --tests '*TerminalizeExpiredNotificationWriterPermitsUseCaseTest' \ + --tests '*RecordNotificationWriterQuiescenceAttestationUseCaseTest' \ + --tests '*SwitchNotificationWriterOwnershipUseCaseTest' \ + --tests '*ReconcileNotificationDeliveriesUseCaseTest' \ + --tests '*NotificationMaintenanceUseCaseTest' \ + --tests '*NotificationCapabilityCompatibilityValidatorTest' \ + --tests '*NotificationOperationsSnapshotUseCaseTest' \ + --console=plain + ``` + + Expected failure: ports/use cases/state transitions do not exist. + +- [x] Implement `NotificationDispatchUseCase` as a manually wired + `CommandUseCase` with + `WRITE`, `IDEMPOTENT`, `WRITE_REPOSITORY`, `externalOutboundAllowed=true` capability metadata. +- [x] Make receipt apply, admission operation, writer-fence initialization, legacy writer permit, + expired-permit terminalization, quiescence attestation, writer ownership switch and maintenance + concrete `CommandUseCase` + implementations too. The + canonical guard is an internal + application policy collaborator invoked only from an existing application write use case, not + a bootstrap-callable `*UseCase`. Annotate every concrete use case with exact existing + capability vocabulary and a type-level permission: + dispatch `notification:dispatch`, receipt apply `notification:receipt`, admission operation + `notification:operate`, fence initialization and ownership switch `notification:cutover`, + legacy writer permit `notification:cutover-admit`, expired-permit terminalization + `notification:cutover-terminalize`, quiescence attestation + `notification:cutover-attest`, maintenance `notification:maintain`. + Receipt apply uses + `WRITE + WRITE_REPOSITORY + IDEMPOTENT` and calls `inRootWrite`; dispatch/maintenance use + `externalOutboundAllowed=true` only when they actually call provider/reconciliation ports. + Do not add a `capabilities.yaml` row because no new capability attribute is introduced. +- [x] Freeze the exact capability matrix: + + | use case | transaction/repository | idempotency | external | direct boundary | + | --- | --- | --- | --- | --- | + | dispatch | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | true | `inWrite` claim/authorize/finalize | + | receipt apply | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | admission operate | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | true | probe outside, then `inWrite` | + | writer fence initialize | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | legacy writer permit | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` acquire/release | + | expired permit terminalize | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | bounded `inRootWrite` | + | writer quiescence attest | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | writer ownership switch | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | reconcile | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | true | `inWrite`, provider outside | + | maintenance | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | bounded `inWrite` | + | operations snapshot | `READ_ONLY` / `READ_REPOSITORY` | `IDEMPOTENT` | false | `inRead` | + + `sensitiveRead=true` for dispatch and reconcile because their safe application models still + carry decrypted recipient/template data or opaque provider references; receipt/admission/ + maintenance may remain false only when tests prove their application values contain + digest/ciphertext/closed reason fields rather than plaintext. `bulkWrite=false` is valid only + because every claim/receipt/maintenance batch is validated `<=100`; raising that cap requires + `bulkWrite=true`. Initial infrastructure dispatch, provider/account admission, reconcile and + retention sweeps span tenant partitions and therefore declare `crossTenantAdmin=true`; + single-correlated-receipt apply remains false. A future tenant-partitioned command may lower + that flag only with query/fitness evidence. Exact permission tokens are + `notification:dispatch`, `notification:receipt`, `notification:operate`, + `notification:cutover-admit`, `notification:cutover-terminalize`, + `notification:cutover-attest`, `notification:cutover`, + `notification:reconcile`, + `notification:maintain`, `notification:observe`. + Operations snapshot is `sensitiveRead=false`, `bulkWrite=false`, + `crossTenantAdmin=true` because it returns only bounded infrastructure aggregates across + partitions. + All writer cutover operations are `sensitiveRead=false`, `bulkWrite=false` and + `crossTenantAdmin=true`. Initialization requires a bounded ordered route/initial-generation + set exactly equal to the application-owned route set derived from the compiled cutover + catalog, its digest, actor/reason/token, absent fences and empty control/data-plane journals. + BEGIN requires a trusted signed complete old-node inventory; quiescence attestation requires + exact route/draining generation, a signed exact inventory/quiescence/consumer/ledger manifest + and token. Actor, post-lock DB-time validity, immutable persisted current+retiring proof + registry, permit/holder sets and node inventory digests are server-derived. Permit + acquire/release require exact route, + LEGACY owner/generation and opaque token; ownership switch requires exact route/expected + generation/token and derives owner/result from the closed action matrix. Switch additionally + requires exact + `BEGIN_DRAIN|COMPLETE_SWITCH|ABORT_DRAIN` action plus audited actor/reason. Initialization, + attestation, terminalization, acquire, release and every switch action reject ambient + transactions and return success only after `inRootWrite` physical commit. A + every COMPLETE additionally succeeds only when the persistence adapter locks and Java + re-verifies the retained signed BEGIN inventory. QUIESCENCE_REQUIRED also revalidates its + signed durable quiescence proof and monotonic zero/irreversible facts. HARD_BOUND forbids + quiescence evidence and accepts only that verified BEGIN, registry-qualified safe terminal + permits and ACTIVE permit 0. +- [x] Keep retry/fallback/admission state policy in application, not mapper/config/scheduler. +- [ ] Make maintenance/reconciliation/retention schedulers call application use cases; app-bootstrap + must not call repositories or persistence entities directly. +- [x] Use injected `Clock`; use bounded batch/deadline/count values; do not sleep inside the use case. +- [x] Verify GREEN with the same command and run: + + ```bash + cd src && ./gradlew :application-core:check --console=plain + ``` + +- [x] Acceptance claim: pure orchestration/state model is proven with fakes; PostgreSQL/provider R2 + is not yet proven. + +**Rollback checkpoint:** Task 4 is the public port boundary. Later adapters may be rolled back by +removing bindings, but these types must remain while compiled consumers exist. + +### Wave A exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + verifyCleanArchitectureDependencies \ + verifyPublicPathSnapshot \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*CleanArchitectureTest' --console=plain + ``` + + Current evidence: the first command passes. The wildcard architecture command executes + `CleanArchitectureTest` successfully (59/59) but remains blocked by the unrelated + `DisabledAdapterArchitectureTest` Redis optional-bean gating violation. The exact + `dev.caskeleton.bootstrap.architecture.CleanArchitectureTest` command passes. + +- [x] Confirm application bytecode/import scan contains no Spring, JPA, Slack, AWS, JSON or HTTP + provider type. +- [ ] Request an application/transaction boundary review before Wave B. +- [x] Update the LLM Wiki branch-note with Wave A evidence and an explicit derived-document decision. + +--- + +## Wave B — Notification-local catalog, rendering and provider protocol + +### Task 5: Build provider/template/route descriptors and binding compiler + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Task 4 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderDescriptor.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationTemplateDescriptor.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalog.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderRuntimeProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/CompiledNotificationBinding.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityDescriptorSource.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompilerTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java` + +- [ ] RED cases: unknown/blank/duplicate local catalog entry; channel/provider mismatch; + durable + legacy/fail-open; receipt-required + unsupported provider; unsafe fallback after + indeterminate; target/retry/reconcile/amplification bound; cyclic binding. +- [ ] RED cutover-catalog cases: + `NotificationCanonicalRouteCatalog` is the retained key-only canonical SSOT and maps with the + trusted runtime target config to application-owned `NotificationCanonicalWriterRouteSet`. + PRE-only `NotificationCutoverRouteCatalog` decorates exactly those keys with legacy aliases + and transport proof metadata and maps to transitional `NotificationWriterRouteSet`; it cannot + add/remove keys. + the sorted route-revision key set, optional legacy alias and each route's bounded + current+retiring legacy transport profile registry are one checked-in PRE SSOT. The registry + marks one active admission profile and, for every revision, proof class + (`HARD_BOUND_PROVEN|QUIESCENCE_REQUIRED`) plus evidence revision; its key + set exactly equals the canonical binding graph and reviewed V8 provenance-bound seed manifest. + Runtime cutover + target generations are a separate exact config/evidence revision and may not add or remove + catalog keys. PRE bridge possible routes must equal this set even when production consumer + count and current legacy route settings are 0. Missing/extra/duplicate alias, route-key drift + and an R0 mapping/profile outside the catalog, an omitted historical blocking profile, absent + proof evidence or a profile marked HARD_BOUND without the reviewed integration evidence + revision fail closed. A retiring profile cannot be removed while any permit/attestation/ + operation/persisted-registry history references it. The registry digest covers the sorted + route/profile/admission-role/proof-class/evidence-revision tuple set and changes on any drift. + A catalog route + with no live legacy consumer is initialized as closed LEGACY predecessor and switched through + the audited protocol; it is never directly seeded canonical in PRE. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationBindingCompilerTest' \ + --tests '*NotificationCanonicalRouteCatalogTest' \ + --tests '*NotificationCutoverRouteCatalogTest' \ + --console=plain + ``` + + Expected failure: canonical descriptors/compiler do not exist. + +- [ ] Implement a pure, deterministic compiler over explicit input; do not inspect Spring beans, + application context, persistence schema or inbound adapters. +- [ ] Keep `expected-state`, exact actual/expected binding IDs, application mode/admission matching, + store capability and ingress topology out of this sibling-local compiler. Task 17 passes + provider-neutral descriptors to the application compatibility validator for those checks. +- [ ] Register only the three initial card IDs. Legacy descriptors must explicitly advertise R0, + no durable/receipt capability. +- [ ] Emit a sorted immutable binding graph and manifest digest; unknown inputs fail closed. +- [ ] Emit one immutable `NotificationCutoverRouteCatalog` and digest from the same route descriptor + inputs over the retained immutable `NotificationCanonicalRouteCatalog`. Bootstrap converts the + canonical catalog to `NotificationCanonicalWriterRouteSet` and the PRE decorator to + `NotificationWriterRouteSet`; neither + legacy settings nor a request may invent/remove route revisions. Bootstrap combines that key + set with the exact reviewed runtime target-generation config; only a reviewed config revision + may change target values after ABORT, and it invalidates qualification evidence. Bootstrap + also maps the catalog's immutable current+retiring transport proof registry into the + application route set; permit acquire uses only the active profile, while timeout, + attestation and COMPLETE must recognize every referenced current/retiring profile even when a + route has zero permits. `NotificationCutoverRouteCatalogTest` freezes the digest algorithm and + proves the exact registry value that batch initialization must persist; after initialization, + PRE composition rejects any persisted/catalog mismatch instead of silently refreshing it. +- [ ] Implement `NotificationPlanPort` by converting the selected adapter-local compiled binding into + an application-owned `NotificationFrozenPlan`. The conversion freezes policy/route/template/ + renderer/provider-leg revisions and contains no credential, SDK, settings or adapter type. +- [ ] Derive the application-owned provider capability descriptor from the actual compiled cards, + renderer and client capabilities. Do not reconstruct “actual” provider facts from the expected + bootstrap settings. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: local graph compatibility only, not actual composition/readiness. + +**Rollback checkpoint:** compiler can coexist dark with the legacy router until Task 18 canonical +composition succeeds. + +### Task 6: Implement immutable local template manifests and bounded renderers + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Task 5 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateManifest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRenderer.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/RenderedNotification.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/SlackBlockKitRenderer.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/TemplateRenderingException.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRendererTest.java` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.subject.txt` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.text.txt` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt` + +- [ ] RED cases: checksum/revision drift; missing/unknown/unused parameter; exact locale fallback + independent of JVM default; email header CR/LF; HTML text/attribute/URL escaping; Slack + mrkdwn/plain-text/mention escaping; output byte/block/depth limits; no file/network/reflection + include; redacted failures. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationTemplateRendererTest' --console=plain + ``` + +- [ ] Implement checked-in resource loading by exact manifest/checksum. Keep business-specific + assets out of production main resources until a consuming project supplies a reviewed catalog; + use test resources only for the generic contract proof. +- [ ] Produce local email subject/text/HTML and Slack Block Kit through typed builders; never accept + caller-supplied arbitrary JSON or provider block objects. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: deterministic local render R1; no provider call. + +**Rollback checkpoint:** retained intent template revisions prevent later asset deletion. Before +durable append, this task is independently reversible. + +### Task 7: Define the adapter-internal one-authorized-attempt SPI + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Tasks 5–6 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/PreparedNotificationAttempt.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAttemptContext.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/AttemptCorrelationId.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ProviderMessageReference.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ReconciliationLookupMode.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderSecretMaterialProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessProbe.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessSnapshot.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderRateAdmission.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapter.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptContractTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandleTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapterTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java` + +- [ ] RED cases: `prepare` has no I/O; one authorization invokes client exactly once; deadline is + absolute/bounded; pre-wire validation maps to definitely-not-applied; possible write timeout + maps indeterminate; accepted response stores only opaque provider reference; SDK exceptions + never escape to application. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationProviderAttemptContractTest' \ + --tests '*NotificationSecretMaterialHandleTest' \ + --tests '*InlineNotificationAttemptAdapterTest' \ + --tests '*NotificationReconciliationAdapterTest' \ + --tests '*NotificationAdmissionReadinessAdapterTest' \ + --console=plain + ``` + +- [ ] Keep this SPI adapter-internal. Implement the application + `NotificationProviderAttemptPort` with compiled binding + renderer + internal client lookup. +- [ ] Keep secret material resolution, control-plane readiness and provider-local rate admission + behind adapter-owned interfaces. Profiles contain secret references/generations only; readiness + snapshots contain bounded non-secret identity/capability facts. +- [ ] Secret acquisition returns a versioned `AutoCloseable` mutable byte/char handle. Acquire it per + provider operation, close it on success/exception/cancellation, wipe on close, reject use after + close, and redact `toString`/exceptions. Never store the raw token in an adapter-owned + record/String/settings field; the wipe claim covers only the adapter-facing mutable copy. +- [ ] Implement the application-owned `NotificationAdmissionReadinessPort` with the adapter-internal + readiness probes. Application admission use cases must never import the internal probe type. +- [ ] Freeze the outbound binding matrix: + `NotificationPlanPort -> NotificationPlanAdapter`, + `InlineNotificationAttemptPort -> InlineNotificationAttemptAdapter`, + `NotificationProviderAttemptPort -> NotificationProviderAttemptAdapter`, + `NotificationReconciliationPort -> NotificationReconciliationAdapter`, + `NotificationAdmissionReadinessPort -> NotificationAdmissionReadinessAdapter`. + Every implementation has a focused contract test before composition. +- [ ] Keep attempt correlation, optional provider operation key and post-response message reference + as distinct types. +- [ ] Verify GREEN with the same command and: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:check --console=plain + ``` + +- [ ] Acceptance claim: deterministic fake protocol R1, no exact provider card qualification. + +**Rollback checkpoint:** no network resources are created until a canonical profile is bound in +Task 18. + +### Wave B exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:notification:check \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Verify the notification leaf has no project dependency on persistence, inbound-web or + httpclient. +- [ ] Request catalog/template/provider-SPI review. +- [ ] Update the LLM Wiki branch-note with Wave B evidence and an explicit derived-document decision. + +--- + +## Wave C — PostgreSQL durable kernel and cryptography + +### Task 8: Add adapter-owned direct AEAD and versioned HMAC primitives + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 4 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCryptoException.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java` + +- [ ] RED cases: AES-256-GCM profile/version, fresh 96-bit nonce per field, context-bound AAD, + ciphertext swapping failure, wrong key/revision failure, purpose-separated length-prefixed HMAC, + current + bounded retiring keys, no key/plaintext in `toString`/exception. +- [ ] Fix the AEAD contract to a 128-bit GCM tag and canonical length-prefixed AAD tuple: + `(schema/table, record ID, notification ID, optional delivery ID, optional attempt ID, + field purpose, provider binding revision, crypto profile version)`. This is the exact approved + design §24.2 hierarchy; key reference/version remain stored non-secret ciphertext metadata but + are not substitutes for the notification/delivery/attempt and binding coordinates. Any + tuple-field swap must fail authentication. +- [ ] Make key acquisition a versioned `AutoCloseable` mutable handle with close-time wipe and + use-after-close failure. Test success, exception and cancellation paths; never retain key bytes + in adapter-owned immutable records/Strings. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationPayloadCryptoTest' \ + --tests '*NotificationHmacDigesterTest' \ + --console=plain + ``` + +- [ ] Use JCA primitives directly; zero temporary mutable key buffers where feasible and never place + material in settings/application records. +- [ ] Do not claim envelope encryption. Persist algorithm/key reference/version/nonce/AAD revision + with ciphertext. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: local cryptographic contract; external key management/rotation readiness is + not yet proven. + +**Rollback checkpoint:** once Task 10 persists ciphertext, old key/AAD/canonicalization revisions +cannot be removed by code rollback. + +### Task 9: Add the additive Notification journal migration and persistence model + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 8 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__notification_delivery_journal.sql` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationIntentEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationDeliveryLegEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationAttemptEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationReceiptEventEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationTechnicalSuppressionEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationAdmissionGateEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationRouteWriterFenceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterOperationEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterOperationRouteEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterTransportProofRegistryEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationRouteWriterPermitEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterQuiescenceAttestationEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterInventoryManifestEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterDrainNodeInventoryEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterQuiescenceManifestEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterQuiescenceNodeEvidenceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterEvidenceTrustSnapshotEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationFreshInstallationProvenanceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterFinalizationDiscriminatorEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationHmacAliasEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationIntentJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationDeliveryLegJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationAttemptJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationReceiptEventJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationTechnicalSuppressionJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationAdmissionGateJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationRouteWriterFenceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationRouteJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterTransportProofRegistryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationRouteWriterPermitJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceAttestationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterInventoryManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterDrainNodeInventoryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceNodeEvidenceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterEvidenceTrustSnapshotJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationFreshInstallationProvenanceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterFinalizationDiscriminatorJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationHmacAliasJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationJournalMigrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterFinalizationDiscriminatorIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationDatabaseRoleIsolationIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationRetainedEvidenceNoSaveArchitectureTest.java` +- `docs/runbooks/notification-database-role-bootstrap.md` + +- [ ] Before editing, scan every Flyway location. If any `V7` exists, stop and reserve the next global + version instead of creating a collision. +- [ ] Add Testcontainers PostgreSQL dependencies to + `src/adapter/outbound/persistence-jpa/build.gradle` and update its lockfile only when the RED + test requires them. +- [ ] After adding only the test harness dependencies, regenerate and review the leaf lock before the + behavior RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:verifyDependencyLocks \ + --console=plain + ``` +- [ ] RED migration tests for all PK/FK/unique/partial-unique/check constraints and eligible/stale + lease/orphan lookup indexes from design §16.8, including one route writer-fence row and + globally unique append-only writer-operation header token/history, unique DB-assigned + `operation_sequence` plus attestation sequence from the same post-lock cutover sequence, + cross-table collision rejection, composite route-child + identity, route-set digest/action/expected/result ownership constraints including bounded + `TERMINALIZE_EXPIRED_PERMITS` requested batch bound and affected count/set digest. Store a + server-canonical header `request_input_digest`; header `route_set_digest` must equal the sorted + exact child set, and request digest must recompute from action-specific persisted header/child + input. Freeze domain-separated length-prefixed SHA-256 profiles + `writer-operation-route-set-v1`/`writer-operation-input-v1` and prove delimiter/order + permutations differ without including PII/secrets. Reject orphan header/child and empty child + sets. Continue with writer-permit token + uniqueness, route/generation/owner scope, + `ACTIVE/RELEASED/EXPIRED_PROVEN/TIMED_OUT_UNPROVEN` checks and blocking-permit lookup index. + Add the retained immutable transport-proof registry with composite + `(route_revision, transport_profile_revision)` PK, exactly one ACTIVE admission profile per + route, one canonical digest per route, initialization-operation-child composite FK and + UPDATE/DELETE rejection. Permit rows freeze + `(route_revision, transport_profile_revision, transport_proof_class, + transport_proof_evidence_revision)` and reference the exact registry row; operation children + and attestations carry the matching registry digest. Add cross-state CHECKs: + `EXPIRED_PROVEN => HARD_BOUND_PROVEN` and + `TIMED_OUT_UNPROVEN => QUIESCENCE_REQUIRED`; `ACTIVE|RELEASED` allow either class. The two + timeout states require a terminalization-operation FK and DB timestamp; ACTIVE/RELEASED forbid + that FK. Persist DB-time `wire_deadline_at` and enforce the reviewed + `acquired_at <= wire_deadline_at < expires_at` shape; the catalog evidence revision supplies + the stricter finalize-margin proof. + Include globally unique immutable quiescence-attestation token, exact + route/draining-generation/BEGIN-operation scope, transport-profile/blocking-permit/permit-holder/ + old-node count/set digests, signed evidence identity, zero/true fact constraints, + observed/expiry bounds and the COMPLETE operation-child attestation-token/set-digest FK. Add an + immutable per-BEGIN node inventory row set whose exact count/digest equals the BEGIN child and + whose node set covers every distinct route permit holder. Caller-written inventory digests are + not accepted. Add immutable per-attestation node evidence rows whose node keys exactly equal + that BEGIN inventory and which retain each deployment-generation tombstone plus legacy + credential-or-egress revocation digest; the canonical row-set digest must equal the + attestation summary. + Add immutable inventory-manifest and quiescence-manifest headers plus an immutable trust + snapshot. Each header retains canonical domain-separated payload bytes/profile, signature, + algorithm, issuer key ID, bounded canonical issuer public-key SPKI/digest, trust catalog + revision, historical-key allow/revocation snapshot and validity profile, + signed issued/expires facts, DB `verified_at`, profile-pinned `allowedClockSkew` and + `acceptanceMargin`, environment/DB/artifact identity, + consumer-inventory identity, provider-ledger identity/snapshot, exact route/drain generation, + and canonical child count/set digest. Header-to-child exact equality is structural and a + zero-node inventory still has exactly one authoritative header. Store no private key or raw + credential. SQL checks bytes/digests/FK/cardinality/state only; application write/startup Java + verifies Ed25519 from the stored payload/signature/SPKI, requires the historical key digest to + remain allowed/non-revoked in the current closed catalog, and enforces + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`. + Add the retained singleton table + `notification_writer_finalization_discriminator`, whose eventual row has the exact closed states + `AWAITING_SIGNED_FRESH_PROVISIONING|FRESH_PROVISIONED|UPGRADE_VALIDATED`. Its structural + XOR is authoritative: `FRESH_PROVISIONED` has exactly one fresh provisioning token and no + validated-history digest; `UPGRADE_VALIDATED` has exactly one validated complete-history digest + and no fresh token; awaiting has neither. No state permits both, an unknown state, or a reverse + transition. The fresh token must equal the provenance initialization token and + `INITIALIZE_CANONICAL_FRESH` operation token; the upgrade digest must equal the + server-canonical complete retained snapshot digest. Freeze the exact columns + `singleton_key=NOTIFICATION_FINALIZATION`, `state`, `fresh_provenance_token`, + `validated_upgrade_history_digest`, `state_operation_token` and `row_version`. + Add the immutable table `notification_fresh_installation_provenance` empty. Freeze these + exact retained axes and names rather than a reduced “zero snapshot”: + + ```text + provenance_token PK + fresh_initialization_operation_token UNIQUE + canonical_signed_payload + canonical_signed_payload_digest + signature_algorithm = ED25519 + detached_signature + issuer_identity_digest + issuer_key_revision + issuer_public_key_spki + issuer_public_key_digest + trust_snapshot_canonical_payload + trust_snapshot_digest + acceptance_window_profile_revision + allowed_clock_skew_ms + acceptance_margin_ms + issued_at + expires_at + server_verified = true + server_verified_at + server_verifier_revision + database_resource_canonical_payload + database_resource_identity_digest + database_birth_certificate_canonical_payload + database_birth_certificate_digest + database_system_identifier_digest + database_identity_digest + schema_identity_digest + environment_identity_digest + final_artifact_digest + canonical_route_set_digest + application_workload_inventory_count = 0 + application_workload_inventory_digest + business_consumer_inventory_count = 0 + business_consumer_inventory_digest + legacy_node_inventory_count = 0 + legacy_node_inventory_digest + provider_call_ledger_identity_digest + provider_call_ledger_snapshot_digest + provider_call_ledger_snapshot_cut_revision + provider_call_ledger_snapshot_cut_at + provider_call_ledger_entry_count = 0 + provider_call_ledger_open_count = 0 + provider_call_ledger_indeterminate_count = 0 + no_legacy_authority_fence_token + no_legacy_authority_fence_revision + no_legacy_authority_fence_canonical_payload + no_legacy_authority_fence_digest + no_legacy_authority_fence_committed_at + no_legacy_authority_fence_read_back_at + no_legacy_authority_fence_irreversible = true + no_legacy_authority_enforcement_revision + no_legacy_authority_enforcement_digest + no_legacy_authority_enforcement_activated_at + no_legacy_authority_enforcement_read_back_at + post_enforcement_zero_manifest_canonical_payload + post_enforcement_zero_manifest_digest + post_enforcement_zero_observation_revision + post_enforcement_zero_observed_at + legacy_deployment_generation_deny_set_digest + legacy_deployment_generation_tombstone_set_digest + legacy_database_credential_issuance_disabled = true + legacy_database_credential_revocation_set_digest + legacy_database_credential_revocation_complete = true + legacy_database_session_inventory_digest + legacy_database_session_open_count = 0 + legacy_database_session_termination_evidence_digest + legacy_database_ingress_denied = true + legacy_database_ingress_denial_policy_digest + legacy_database_ingress_blocks_established_flows = true + legacy_provider_credential_issuance_disabled = true + legacy_provider_credential_revocation_set_digest + legacy_provider_credential_revocation_complete = true + legacy_provider_connection_flow_inventory_digest + legacy_provider_connection_flow_open_count = 0 + legacy_provider_connection_flow_termination_evidence_digest + provider_egress_denied = true + provider_egress_denial_policy_digest + provider_egress_blocks_established_flows = true + authorization_digest + ``` + + The external infrastructure issuer must first commit and read back the exact irreversible + enforcement revision, then terminate every pre-existing legacy DB session and provider + connection/flow, then observe the causally later post-enforcement zero/settled manifest, + including ledger entry/open/indeterminate counts 0, then seal-commit and read back the + permanent fence, and only then sign the DB-birth authorization. Enforce + `no_legacy_authority_enforcement_activated_at + <= no_legacy_authority_enforcement_read_back_at + <= provider_call_ledger_snapshot_cut_at <= post_enforcement_zero_observed_at + <= no_legacy_authority_fence_committed_at + <= no_legacy_authority_fence_read_back_at <= issued_at`; every source evidence binds the exact + fence token and enforcement revision. Pre-enforcement zero snapshots, sign-before-seal, + cross-revision composition, a reopened cached session/flow and any shortened or renamed axis + fail with mutation 0. V7 creates only empty structure and V8 never manufactures either + authority. Migration/discriminator tests reject every discriminator XOR violation and + independently remove, alter or make nonzero/false each exact DB-birth/fence/enforcement/ + post-enforcement/ledger/session/connection axis above; no partial provenance row is valid. + Operation-child CHECKs encode the closed action/result matrix and forbid + `DRAINING/CANONICAL`, caller-selected target owners and any CANONICAL→LEGACY history. +- [ ] Establish exactly three database roles in the real-PostgreSQL fixture before GREEN and reject + every additional notification-scoped owner/member/grantee: + pre-provisioned `notification_migrator` owns Flyway history, notification schema, tables, + sequences, trigger/functions and is the only Flyway principal; `notification_runtime` is a + non-owner with only the exact runtime DML/SELECT grants needed by the active release and, in + the PRE artifact only, the exact transitional function `EXECUTE` grants; + `notification_provisioner` has no table/sequence privilege and is reserved for the V8-created + two-function fresh-provisioning protocol. The FINAL cleanup migration revokes every PRE + transitional `EXECUTE` grant from runtime/PUBLIC. Object ownership and default privileges must + make REVOKE effective; running Flyway as the runtime user is a RED failure. + Every `SECURITY DEFINER` function is owned by `notification_migrator`, schema-qualified, uses a + fixed safe `search_path`, contains no dynamic SQL, revokes PUBLIC EXECUTE and grants only the + exact role. Tests cover direct table DML, sequence use, function invocation, role switching, + search-path shadowing and forged input. +- [ ] Make role creation an explicit external DB-admin/IaC prerequisite, not a migration side + effect. `docs/runbooks/notification-database-role-bootstrap.md` freezes the exact principal + set, LOGIN/NOINHERIT expectations, external credential references, database/schema ownership + handoff and read-only verification queries without embedding credentials. A privileged + Testcontainers setup connection may emulate that prerequisite before Flyway, then must close; + Flyway starts only afterward as `notification_migrator`. V7/V8 contain no `CREATE ROLE`, + credential generation or membership grant: they validate `current_user`, exact + owner/member/grantee inventory and object/default privileges, create/alter owned schema + objects, and perform the reviewed grants/revokes. Production evidence retains the external + bootstrap revision/digest, all three `current_user` probes and the post-migration privilege + snapshot. Missing bootstrap evidence or a fourth notification-scoped principal stops rollout + before Flyway. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationJournalMigrationTest' \ + --tests '*NotificationWriterFinalizationDiscriminatorIntegrationTest' \ + --tests '*NotificationDatabaseRoleIsolationIntegrationTest' \ + --tests '*NotificationRetainedEvidenceNoSaveArchitectureTest' \ + --console=plain + ``` + + Expected failure: `V7` and journal schema do not exist. + +- [ ] Implement additive tables only. Do not rewrite existing outbox/idempotency tables and do not + backfill historical events. +- [ ] Treat signed headers/trust snapshots, drain inventory, per-node quiescence evidence, fresh + provenance and writer-finalization discriminator entities/repositories as retained + adapter-internal audit projections. Only the + BEGIN adapter may insert a verified inventory header/children, only the attestation adapter may + insert a verified quiescence header/per-node evidence in its root transaction, and only the + two-function final provisioning protocol may insert provenance and CAS + `AWAITING_SIGNED_FRESH_PROVISIONING -> FRESH_PROVISIONED`; V8 alone may establish + `AWAITING_SIGNED_FRESH_PROVISIONING` or `UPGRADE_VALIDATED`. From Task 9 onward, provenance, + discriminator, signed-header/trust-snapshot and other never-Java-written retained repositories + extend only Spring Data's marker `Repository` and expose bounded named reads; they never + inherit `CrudRepository`/`JpaRepository` or declare `save`, `saveAll`, `delete` or `flush`. + `NotificationRetainedEvidenceNoSaveArchitectureTest` enforces that initial surface. Retain + every projection for FINAL startup/evidence reads. +- [ ] Store provider leg separately from logical recipient; enforce one open attempt per delivery + and one active leg per fallback strategy group in PostgreSQL. +- [ ] Keep raw recipient/parameter/provider payload/error out of plaintext columns and indexes. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: schema invariants on real PostgreSQL, not yet append/claim behavior. + +**Rollback checkpoint:** deploy schema before code. Do not use destructive down migration; old code +must tolerate additive tables. If later ciphertext/state is incompatible with old code, rollback is +forward-fix. + +### Task 10: Implement same-transaction append, dedupe aliases and frozen plan storage + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 9 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationPersistenceMapper.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationPersistenceExceptionTranslator.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationStoreCapabilityDescriptorSource.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationIntentAppendIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationStoreCapabilityDescriptorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationSameTransactionAppendContractTest.java` + +- [ ] RED cases on real PostgreSQL: + business write + append commit together; either failure rolls both back; append never uses + `REQUIRES_NEW`; same idempotency digest/fingerprint returns existing intent; different + fingerprint conflicts; concurrent old/current HMAC alias writers resolve to one semantic owner; + frozen legs/template/route/crypto revisions are immutable. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationIntentAppendIntegrationTest' \ + --tests '*NotificationStoreCapabilityDescriptorTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationSameTransactionAppendContractTest' --console=plain + ``` + +- [ ] Encrypt recipient/parameters before persistence and insert all current + retiring HMAC aliases + in the same caller transaction. +- [ ] Seed representative recipient, parameter, provider payload, error and key-marker values, then + scan every notification table/index-visible text representation and captured SQL/log output. + The markers may appear only after an explicit decrypt operation in test memory; database + plaintext evidence must be zero. +- [ ] Map unique conflicts to typed duplicate/mismatch results; never catch-and-ignore arbitrary + constraint errors. +- [ ] Derive the application-owned store descriptor from the actual migration/schema, crypto + profile/key generations and live/retained revision inventory. Expected bootstrap config is not + an input to this source. +- [ ] Verify GREEN with the same commands. +- [ ] Acceptance claim: same-DB durable append is locally verified only for the tested PostgreSQL + topology; no provider card R2 is implied. + +**Rollback checkpoint:** leave canonical binding disabled. Schema/data stay in place if code is +rolled forward; never resend persisted rows through legacy code. + +### Task 11: Implement PostgreSQL claim, wire authorization, terminal-once result and admission gate + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 10 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlNotificationClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationAdmissionGateStore.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationReceiptStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationTechnicalSuppressionStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationCanonicalWriterFenceAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterQuiescenceAttestationAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationClaimIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationAdmissionGateIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationReceiptStoreIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationCanonicalWriterFenceIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterCutoverIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterQuiescenceAttestationIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterIrreversibleFenceIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotIntegrationTest.java` + +- [ ] RED concurrency cases with at least two transaction contexts: + `SKIP LOCKED` single owner; bounded ordering/aging; exact token/version predicate; stale owner + cannot overwrite; gate generations locked in canonical order; only active generations may + commit `WIRE_AUTHORIZED`; one terminal exact result per execution token; late exact result may + fill its slot without stale projection overwrite. +- [ ] RED park/restart cases: + provider/account fault closes shared gate and parks backlog; another node sees it; restart + preserves it; resume increments generation and rechecks expiry/cancel/suppression; no hot loop + and no initial fallback. +- [ ] RED writer-fence/permit cases: + atomic route owner/generation/state CAS; all nodes observe one owner; stale legacy and canonical + generations both fail closed. Adapter/DB tests enumerate the closed transition matrix: + BEGIN only `ACTIVE/LEGACY@g -> DRAINING/LEGACY@g`, terminalize only unchanged + `DRAINING/LEGACY@g`, COMPLETE only + `DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1`, ABORT only + `DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1`; every action from CANONICAL, target-owner input and + `DRAINING/CANONICAL` fail with mutation 0. Unique bounded permit acquire/release/expiry uses DB time and + exact token/version; `BEGIN_DRAIN` and concurrent acquire serialize so no new permit commits + after DRAINING; `COMPLETE_SWITCH` and the operations snapshot require ACTIVE legacy permit 0 + for the exact route across every historical fence generation, not merely the current + generation; `ABORT_DRAIN` emits a new LEGACY generation without hiding an older-generation + live permit. Prove `g` permit active -> abort to `g+1` -> drain again -> complete remains + blocked until the `g` permit is terminal. A canonical caller transaction holds a fence share + lock through its append commit, so concurrent `BEGIN_DRAIN` cannot commit first and reopen + legacy while a stale canonical append later commits; owner switch does not rewrite already + accepted canonical rows. +- [ ] RED timeout/attestation concurrency cases: + a proven hard-bound profile may CAS past-deadline ACTIVE to `EXPIRED_PROVEN`; an unproven + profile may only become `TIMED_OUT_UNPROVEN`. For current R0, COMPLETE fails even with permit + count 0 until an authenticated attestation committed after BEGIN_DRAIN is supplied. Permit + acquire locks the persisted ACTIVE registry row and freezes its proof class/evidence revision; + timeout, attestation and COMPLETE reject a permit whose frozen tuple differs or whose profile + is unknown. Mixed-profile fixtures prove `QUIESCENCE_REQUIRED + EXPIRED_PROVEN` and + `HARD_BOUND_PROVEN + TIMED_OUT_UNPROVEN` are rejected by both DB and runtime. Recording + BEGIN first verifies an independently signed exact environment/DB/route/PRE-artifact complete + node inventory and freezes its rows/count/digest atomically with the fence CAS. Missing/ + duplicate/extra node, unknown issuer, wrong environment/DB/artifact/profile and any historical + permit holder omitted by the manifest fail with mutation 0. Attestation recording + first requires ACTIVE 0, then locks/snapshots every-generation/multi-profile + `(permit token,generation,transport profile,state,rowVersion)` TIMED_OUT_UNPROVEN tuple and + distinct holder set and stores the bounded canonical set/count/digests plus the persisted route + registry and exact frozen BEGIN inventory digest. Its independently signed quiescence manifest + must list that exact node set, bind per-node retired/quiesced facts plus irreversible + deployment-generation/legacy-credential/egress fences, consumer inventory/count 0 + and provider-call ledger identity/open-count 0; caller digests are ignored/rejected. COMPLETE + requires the persisted per-node evidence key set and tombstone/revocation row-set digest to + exactly match the signed manifest, attestation summary and BEGIN inventory. Missing/extra/ + duplicate node evidence or a digest-only attestation fails. The retained BEGIN inventory + header, quiescence/attestation header and evidence trust snapshot persist the canonical signed + payload bytes, signature, issuer/key identity, bounded issuer public-key SPKI/digest, verified + trust/historical-key snapshot, issued/expires/verified times and pinned + skew/acceptance-margin profile, environment/DB/PRE-artifact identity, exact node/consumer + inventory identity and provider-ledger identity/snapshot. An exact zero-node inventory is + still an issuer-authorized + signed statement, never an unsigned empty shortcut. Java verifies Ed25519 and exact identity + at evidence admission and again at startup; database constraints enforce only immutable + shape, FK, count and digest structure. Expiry rejects new evidence admission but does not make + an already committed irreversible tombstone, credential revocation or egress revocation + reversible. + COMPLETE + locks fence, registry, BEGIN inventory, attestation and permit rows in canonical order, + recomputes exact equality, appends its attestation token/digest and fence CAS in one root + transaction. Cover missing/stale/wrong-route/wrong-drain-generation/wrong-profile-set, + unknown, extra or omitted active/retiring profile, catalog/persisted-registry drift, tampered + proof class/evidence revision/registry digest, partial multi-profile/node/holder coverage, + unsigned/wrong-key/wrong-identity evidence, nonzero facts, changed set after release/timeout, + token replay mismatch, concurrent attestation/permit terminal transition and commit + failure/result loss. + No TTL-only path may reach CANONICAL. +- [ ] RED the irreversible COMPLETE proof and stale-node resume race on the exact PostgreSQL 16 + profile: + COMPLETE requires the exact frozen BEGIN inventory, signed retained quiescence evidence, + irreversible deployment-generation tombstones, legacy credential revocations and egress + revocations, ACTIVE permit 0 and provider-ledger open-count 0 in one canonically locked + snapshot. Pause an old bridge process after it has cached its legacy credential, provider + client and connection but before provider I/O. Commit the irreversible evidence and + `COMPLETE_SWITCH`, then resume the stale process and prove provider I/O remains 0 because the + old deployment generation, credential and egress path are all unusable. Repeat after + application/JDBC connection recreation and process restart. Missing/reversible facts, + a changed inventory/ledger identity, nonzero permit/ledger state, unsigned or non-reverifiable + retained headers, and any stale node omitted from BEGIN are `NOT_QUALIFIED` and prohibit + COMPLETE/21C. Commit-success/result-loss replay returns the stored durable result without + weakening or refreshing the evidence. +- [ ] RED hard-bound pause/resume cases before any profile may use `HARD_BOUND_PROVEN`: + acquire root commit freezes `wire_deadline_at + finalize_margin <= expires_at`; commit before + provider I/O; the wrapper/client cannot begin network I/O after that absolute deadline and + cancellation/connection close completes by it. Pause immediately after acquire commit, let + wire deadline and permit expiry pass, terminalize/COMPLETE, then resume: provider call count is + 0. Resume just before the wire deadline: any started call ends by the same deadline. Include + commit-ack delay, scheduler pause and clock-skew/rollback bounds. Without all evidence the + catalog must classify the profile `QUIESCENCE_REQUIRED`. +- [ ] RED terminalizer execution cases independently of durable workers: + the read-only snapshot never mutates a permit; exact DRAINING fence + DB-time expiry + + persisted registry are required; a bounded batch scans all historical generations and CASes + each exact token/rowVersion once; concurrent release/terminalize and two terminalizers have one + terminal winner per permit. Operation header/route affected set digest and permit + terminalization FK commit all-or-none, commit-before-2xx is observable at Task 17, and + commit-success/result-loss same-token replay returns the stored affected set even when current + selection is empty. Different tokens consume successive bounded batches deterministically. + Idempotency lookup/recomputed `request_input_digest` precedes selection; same token with batch + bound `10` then `100`, changed actor/reason/route/drain generation or action conflicts with + mutation 0. + PURE_DISABLED has no terminalizer bean/thread; PRE bridge + and CUTOVER_WAIT compose the proxied operation without any scheduler or provider I/O. +- [ ] RED initialization/audit-journal cases: + two distinct concurrent batch initialization tokens have exactly one winner; a + same-token/same-route-set/input replay returns the stored full result; a two-route fixture + commits all fences/header/children and the full immutable transport-proof registry snapshot or + none; partial/extra/missing route/profile set and absent fences plus a nonempty row in any + control/data-plane journal, including an orphan registry row, reject without mutation; + initialization commit failure rolls back every fence, operation header/children and registry + row; + commit-success/result-loss is recoverable from the immutable operation journal; token + uniqueness is global and exact route-set/action/input mismatch fails closed. For initialization and + `BEGIN_DRAIN|TERMINALIZE_EXPIRED_PERMITS|COMPLETE_SWITCH|ABORT_DRAIN`, append the operation + header/route results and applicable fence/permit + mutations atomically, update each changed fence's `last_operation_token` to that header, + retain the full history after later operations, and replay an old token after newer operations. + Verify the fence pointer is only a latest-result integrity pointer and deleting/overwriting an + older operation or updating/deleting a registry row is impossible. Replaying initialization + returns the stored registry digest and cannot refresh it from a changed catalog. + The adapter assigns `operation_sequence`, and attestation its sequence from the same DB + sequence, only after the batch-init/global lock or exact route fence lock; committed route + history has one causal total order despite concurrent actions, while rollback/global gaps are + harmless. Populate operation/terminalization/attestation times only with post-lock + `clock_timestamp()`. Start a terminalizer transaction before BEGIN, block it on the fence, then + let BEGIN commit: its later sequence and timestamp must both follow BEGIN; a + `CURRENT_TIMESTAMP`/transaction-start implementation is a RED failure. Recompute + `route_set_digest`/`request_input_digest` from + persisted header/children on replay; orphan header/child, empty child set and digest mismatch + fail closed. + A fake port test is not accepted as evidence for empty-journal checking, concurrency or + physical commit semantics. +- [ ] RED receipt cases: + outer/semantic dedupe, orphan-before-accepted, later attach, conflict quarantine, and atomic + persistence of an explicit suppression mutation or explicit no-op supplied by application. + Persistence does not classify bounce/complaint policy. +- [ ] RED the bounded fresh writer snapshot needed by PRE activation: + exact route key set plus owner/state/generation and all-generation blocking permit aggregates + plus persisted transport-proof registry digest/profile aggregates are read through + `NotificationOperationsSnapshotAdapter`; stale/missing/partial rows are explicit results, not + silently cached success. This adapter is implemented in Task 11 so Task + 17 can compose only `NotificationOperationsSnapshotUseCase`, never a repository or outbound + port. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*PostgreSqlNotificationClaimIntegrationTest' \ + --tests '*NotificationAdmissionGateIntegrationTest' \ + --tests '*NotificationReceiptStoreIntegrationTest' \ + --tests '*NotificationCanonicalWriterFenceIntegrationTest' \ + --tests '*NotificationWriterCutoverIntegrationTest' \ + --tests '*NotificationWriterQuiescenceAttestationIntegrationTest' \ + --tests '*NotificationWriterIrreversibleFenceIntegrationTest' \ + --tests '*NotificationOperationsSnapshotIntegrationTest' \ + --console=plain + ``` + +- [ ] Implement vendor SQL only in `.postgresql`; keep JPA entities/repositories adapter-local. +- [ ] Use DB time consistently for claim/lease comparisons and bounded batch sizes. +- [ ] Keep render/provider calls out of every repository transaction. +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:outbound:persistence-jpa:check --console=plain`. +- [ ] Acceptance claim: durable local primitives have real-PostgreSQL evidence; dispatcher fault + matrix is Task 12 and no provider card R2 is implied. + +**Rollback checkpoint:** pause new admission/worker first. Preserve all active attempt/gate revisions +and inspect accepted/indeterminate inventory before any code rollback. + +### Task 12: Prove the deterministic dispatcher/reaper fault matrix across application and PostgreSQL + +**Owner:** `app-bootstrap` integration harness (`:app-bootstrap`) +**Depends on:** Tasks 4, 7, 11 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationDispatcherPostgresContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationDispatcherCrashMatrixTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationDispatcherContainerSupport.java` + +- [ ] Build a deterministic fake provider with explicit barriers before/after + `WIRE_AUTHORIZED`, wire call, response and finalize; no actual network. +- [ ] RED matrix: + + | crash/fault point | expected restart result | + | --- | --- | + | before claim commit | eligible, no attempt | + | after claim before reserve | lease requeue, no provider call | + | after reserve before `WIRE_AUTHORIZED` | safe requeue, no provider call | + | `WIRE_AUTHORIZED` transaction fails to commit | authorization absent, provider call 0, safe requeue | + | authorization commit succeeds but caller loses commit result | current worker calls provider 0; only reaper acts after deadline/grace | + | after `WIRE_AUTHORIZED` before call | wait through deadline/grace, then reconcile or terminal indeterminate | + | after possible write before response | no blind retry/fallback | + | accepted response before finalize | late exact result or reconcile; duplicate risk explicit | + | stale worker finalize after new owner | exact fact may append once; projection CAS rejected | + | binding park racing authorization | pre-park authorization completes boundedly; later authorizations blocked | + +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationDispatcherPostgresContractTest' \ + --tests '*NotificationDispatcherCrashMatrixTest' \ + --console=plain + ``` + +- [ ] Wire real `TransactionPort`, store and application dispatcher manually in the test; do not + introduce production scheduler/composition yet. +- [ ] For lost commit-result ambiguity, prove the reaper reads the committed authorization only after + deadline/grace and chooses provider reconciliation when the exact card supports it, otherwise + terminal `INDETERMINATE`; it never treats the occurrence as definitely-not-sent. +- [ ] Verify provider invocation occurs outside actual transaction. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: provider-neutral durable protocol is locally verified; actual process-kill + evidence is Task 20 and Slack/SES cards remain unqualified. + +**Rollback checkpoint:** this is test-only integration. Production remains dark. + +### Wave C exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*Notification*' \ + --console=plain + cd src && ./gradlew :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + --console=plain + cd src && ./gradlew verifyCleanArchitectureDependencies \ + verifyDependencyLocks --console=plain + ``` + +- [ ] Capture PostgreSQL version, container image digest, test seed and fault matrix results. +- [ ] Request durability/concurrency/crypto review before provider work. +- [ ] Update the LLM Wiki branch-note with Wave C evidence and an explicit derived-document decision. + +--- + +## Wave D — Slack and SES send providers + +### Task 13: Implement Slack Web API `chat.postMessage` protocol + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Tasks 5–7 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiRuntimeProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiCredentialHandle.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiAttemptClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiOutcomeMapper.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiCapabilityCards.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiReadinessProbe.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiRateAdmission.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiProtocolTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiReadinessTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiRateAdmissionTest.java` + +**Files — modify:** + +- `src/adapter/outbound/notification/build.gradle` +- `src/adapter/outbound/notification/gradle.lockfile` + +- [ ] First run a bounded dependency spike against the official Slack Java SDK. Prove endpoint, + TLS/proxy/timeouts, connection lifecycle and retry count are controllable. If not, stop and + amend the plan before adding a provider-local HTTP engine. +- [ ] RED loopback protocol cases: + exact `chat.postMessage` method/payload; one channel target; one physical request per + authorization; bearer secret redaction; success `(channel, ts)` -> accepted/conversation + reference; explicit `ok=false`; 429/`Retry-After`; auth/scope/account rejection -> park; + timeout/connection loss/undecodable success -> indeterminate; payload/Block Kit bounds. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*SlackWebApiProtocolTest' \ + --tests '*SlackWebApiReadinessTest' \ + --tests '*SlackWebApiRateAdmissionTest' \ + --console=plain + ``` + +- [ ] Add the minimum official SDK dependency, disable SDK retry, update the affected lockfile with + the repository lock workflow, and verify actual request count in every test. +- [ ] Regenerate and verify the exact notification leaf lock after the SDK declaration: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :adapter:outbound:notification:verifyDependencyLocks \ + --console=plain + ``` + +- [ ] Implement bounded provider-local admission and a safe control-plane readiness probe using + Slack `auth.test`; verify workspace/token identity, scopes/card requirements and rate state + without logging token/channel/message content. +- [ ] Key Slack admission by exact `(workspaceBindingRevision, channelIdDigest, chat.postMessage)` + scope; cap `Retry-After` by the attempt deadline/retry horizon and prove concurrent token-bucket + bounds with an injected monotonic clock. +- [ ] Acquire/close `SlackWebApiCredentialHandle` per protocol/probe call and test wipe on success, + mapped exception, timeout and cancellation. +- [ ] Implement both descriptor cards: + `slack-web-api-inline-single-local-v1` and + `slack-web-api-durable-single-local-v1`; do not add webhook semantics to either. +- [ ] Document that response-loss without `ts` has no safe blind retry/native idempotency. +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:outbound:notification:check + :adapter:outbound:notification:verifyDependencyLocks --console=plain`. +- [ ] Acceptance claim: Slack local protocol R1; sandbox Task 19 is required for card R2. + +**Rollback checkpoint:** remove canonical Slack binding first so client/resources become zero; do not +send accepted/indeterminate durable intents through webhook fallback. + +### Task 14: Prove Slack inline and durable transaction semantics + +**Owner:** `app-bootstrap` integration harness (`:app-bootstrap`) +**Depends on:** Tasks 2, 12, 13 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/SlackInlineTransactionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/SlackDurableDispatchContractTest.java` + +- [ ] RED inline cases: + provider call only after root commit; root rollback/commit failure/ambient transaction rejection + -> call 0; returned `InlineCompleted` keeps target outcome; no durable retry claim. +- [ ] RED durable cases: + append joins business transaction; provider outside transaction; response loss becomes terminal + unknown/reconcile unsupported; no blind retry or fallback. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*SlackInlineTransactionContractTest' \ + --tests '*SlackDurableDispatchContractTest' \ + --console=plain + ``` + +- [ ] Use the loopback Slack endpoint/client profile, not live Slack. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: mode-specific local protocol/config evidence; not sandbox R2. + +**Rollback checkpoint:** both modes remain unbound by default. No migration data is resent. + +### Task 15: Implement Amazon SES v2 one-recipient submission protocol + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Tasks 5–8 and Task 13, because both provider tasks modify the same +`build.gradle`/`gradle.lockfile` and must serialize those edits + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2RuntimeProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesCredentialSourceProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2AttemptClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2OutcomeMapper.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2CapabilityCards.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2ReadinessProbe.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2RateAdmission.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2ProtocolTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2ReadinessTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2RateAdmissionTest.java` + +**Files — modify:** + +- `src/adapter/outbound/notification/build.gradle` +- `src/adapter/outbound/notification/gradle.lockfile` + +- [ ] RED loopback protocol cases: + SES v2 `SendEmail`; exactly one recipient; local-rendered subject/text/HTML; reviewed + from-identity/configuration-set; fixed EmailTag `ca_attempt_v1` with opaque pre-send correlation; + SDK max physical attempt 1; `MessageId` -> provider accepted only; throttle/auth/account mapping; + timeout/connection loss -> indeterminate; no PII/credential in telemetry. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*SesV2ProtocolTest' \ + --tests '*SesV2ReadinessTest' \ + --tests '*SesV2RateAdmissionTest' \ + --console=plain + ``` + +- [ ] Add only required AWS SDK v2 SES/client modules under the existing BOM version. Disable SDK + retry for mutation sends and verify request count. +- [ ] Regenerate and verify the exact notification leaf lock after the AWS SDK declaration: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :adapter:outbound:notification:verifyDependencyLocks \ + --console=plain + ``` + +- [ ] Implement bounded quota/send-rate admission and a safe SES control-plane probe for exact + account/region/sandbox/sending-enabled/quota/from-identity/configuration-set facts. Secret + credential values remain outside the readiness snapshot. +- [ ] Key SES admission by exact account/region/binding revision, intersect local token-bucket limits + with current provider quota/send-rate and cap waits by the attempt deadline. The resolved + credential source/generation is a readiness fact; only credential secret values are excluded. +- [ ] Implement only `aws-ses-v2-durable-single-local-sns-v1`; no multi-recipient, stored-template, + SMTP or Gmail aliases. +- [ ] Treat the EmailTag as correlation, never provider idempotency. +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:outbound:notification:check + :adapter:outbound:notification:verifyDependencyLocks --console=plain`. +- [ ] Acceptance claim: SES local submission protocol R1; SNS and sandbox evidence still required. + +**Rollback checkpoint:** remove binding before client/SDK rollback. Preserve correlation/message +references and never replay indeterminate sends automatically. + +### Wave D exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*Slack*' --tests '*Ses*' \ + --console=plain + cd src && ./gradlew :adapter:outbound:notification:check \ + --console=plain + cd src && ./gradlew :adapter:outbound:notification:verifyDependencyLocks \ + verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Record actual loopback request counts and dependency/CVE/license review. +- [ ] Request provider protocol review before ingress/composition. +- [ ] Update the LLM Wiki branch-note with Wave D evidence and an explicit derived-document decision. + +--- + +## Wave E — SNS receipt, canonical composition and operations + +### Task 16: Implement verified SNS HTTPS ingress and normalized SES receipt mapping + +**Owner leaf:** `adapter-inbound-web` (`:adapter:inbound:web`), with the physical-commit integration +owned by `app-bootstrap` (`:app-bootstrap`) +**Depends on:** Tasks 4, 11, 15 + +**Files — create:** + +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressProfile.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressDescriptor.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressCapabilityDescriptorSource.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsNotificationController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsMessageEnvelope.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsSignatureV2Verifier.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsSigningCertificateLoader.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SesReceiptNormalizer.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/VerifiedNotificationReceiptOperation.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressException.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/SnsNotificationControllerTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/SnsSignatureV2VerifierTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/SesReceiptNormalizerTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressCapabilityDescriptorTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/VerifiedNotificationReceiptOperationTest.java` + +**Files — modify:** + +- `src/.env` +- `docs/security/public-paths-snapshot.txt` + +- [ ] Fix the exact endpoint to `/webhooks/notifications/aws-ses-v1`; append it to the comma-separated + `SECURITY_PUBLIC_PATHS` SSOT in `src/.env`, then regenerate the reviewed snapshot with: + + ```bash + cd src && ./gradlew verifyPublicPathSnapshot \ + -PapprovePublicPathChange --console=plain + ``` + + `SecurityConfig` already reads `SecuritySettings.publicPaths`; do not hard-code a matcher in + Java. The endpoint bypasses JWT only and still requires SNS verification before + normalization/use-case invocation. +- [ ] RED cases: + bounded POST/content-type/body/depth; SignatureVersion 2 canonical string; signature/key + rotation; HTTPS allowlisted SNS cert host/path; DNS/IP/redirect/chain/expiry/SSRF rejection; + exact TopicArn account/region/name. Freeze the exact card's + `maxCallbackAge = SNS HTTP retry horizon + DLQ retention/redrive horizon + clock skew`. + `ses-notification-v1` fixes `1h + 7d + 5m = 7d1h5m`, outer tombstone `8d` and inner semantic + tombstone `30d`; the ingestion safety margin is `1h`. Arithmetic overflow or topology drift + fails composition. + A signed delayed retry just inside the bound is accepted/deduped, one just outside is rejected + 4xx with receipt/quarantine DB mutation 0. Retention refuses tombstone expiry while the window + is open; a forced-corruption fixture with a missing tombstone but retained semantic receipt + fact still cannot reapply because of the store unique invariant. An outside-window replay + remains age-rejected even after a deliberately expired tombstone. + Outer/semantic tombstones outlive max age plus safety margin; supported + event mapping and unknown schema/event quarantine remain covered. + A signed timestamp beyond the allowed future skew is also rejected without mutation. +- [ ] RED controller cases: + verified receipt transaction commit before 2xx; transient store/commit failure returns `503`; + bounded ingress overload returns `429` only before receipt admission and before any commit; + duplicate returns idempotent 2xx; authenticated but unsupported schema/event is durably + quarantined and then ACKed 2xx; invalid signature/topology is rejected 4xx without persistence; + subscription/unsubscribe confirmation never fetches arbitrary URL; raw body/header/DTO never + reaches application/log. Never return success for a receipt whose commit outcome is unknown. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*SnsNotificationControllerTest' \ + --tests '*SnsSignatureV2VerifierTest' \ + --tests '*SesReceiptNormalizerTest' \ + --tests '*NotificationReceiptIngressCapabilityDescriptorTest' \ + --tests '*VerifiedNotificationReceiptOperationTest' \ + --console=plain + ``` + +- [ ] Implement cert retrieval with a bounded inbound-adapter-local JDK client and strict allowlist; + do not add a project edge to outbound httpclient/notification. +- [ ] Normalize only after authenticity/topology validation to + `NormalizedNotificationReceiptCommand`. +- [ ] Derive the application-owned ingress capability descriptor from the actual verifier, endpoint, + TopicArn/signature profile, ACK/DLQ contract, retry/DLQ/redrive horizons, max callback age and + tombstone retention. Do not rebuild “actual” ingress facts from expected bootstrap settings. +- [ ] Define those bounded durations in the checked-in `NotificationReceiptIngressProfile`, not an + unrestricted request/env override. Reject invalid arithmetic, overflow, tombstone + `<= maxCallbackAge + safety margin` and a topology descriptor that cannot prove the exact + horizons. An original envelope older than max age is never directly replayed. A separate + authenticated/approved operator procedure republishes the inner SES event through the exact + TopicArn to create a new signed outer envelope while preserving the inner semantic fingerprint; + it is allowed only while semantic dedupe retention remains. +- [ ] Compose `ApplyNotificationReceiptUseCase` manually behind the verified inbound controller so + provider signature authentication is not confused with JWT role authentication. The use case + still declares the required application capability/permission contract and performs its write + through `TransactionPort.inRootWrite`. +- [ ] The auto-scanned controller injects only inbound-local + `VerifiedNotificationReceiptOperation`, whose method accepts/returns application command/result + types. Task 17 supplies a non-advised lambda/implementation bean that captures a distinct + manually constructed `ApplyNotificationReceiptUseCase`; the use case itself is not a Spring + bean. Controller tests prove raw/unverified requests cannot reach this seam, and composition + tests prove it is not a method-security target and cannot be confused with the operator path. +- [ ] Add + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/SesSnsReceiptCommitAckContractTest.java` + using real PostgreSQL + MockMvc/test server. Prove 2xx is emitted only after physical commit; + commit failure/rollback invokes no success ACK, while a committed duplicate returns idempotent + 2xx. +- [ ] Run the cross-leaf RED/GREEN integration with: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*SesSnsReceiptCommitAckContractTest' --console=plain + ``` +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:inbound:web:check --console=plain` and: + + ```bash + cd src && ./gradlew verifyPublicPathSnapshot \ + verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Acceptance claim: offline verified ingress protocol; actual AWS SNS callback remains Task 19. + +**Rollback checkpoint:** before endpoint removal, pause event destination and inventory SNS retries, +DLQ and orphan receipts. Do not drop the inbox while retries are possible. + +### Task 17: Add canonical graph settings, an inactive cutover bridge and zero-resource disabled mode + +**Owner:** `app-bootstrap` plus the thin transitional `adapter-inbound-web` endpoint +**Depends on:** Tasks 5, 10–16 + +**Files — create:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSecretMaterialBridge.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWorkerRuntimeProfile.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterStartupMode.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGate.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSameDataSourceTopologyValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleComposition.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleTopologyValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPort.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationZeroResourceTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationSecretMaterialBridgeTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCanonicalSameTransactionCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCanonicalWriterFenceSetCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGateTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationInternalTrustContextCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPortTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCutoverAuthorizationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationFlywayRoleIsolationTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationWriterOwnershipCommitAckContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/NotificationSecretEnvContractTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/cutover/Ed25519NotificationWriterInventoryEvidenceVerifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/cutover/NotificationWriterEvidenceTrustCatalog.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/cutover/Ed25519NotificationWriterInventoryEvidenceVerifierTest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipResponse.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipControllerTest.java` + +**Files — modify:** + +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunner.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidator.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunnerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/FlywayMigrationCompatibilityContractTest.java` +- `src/.env` +- `src/build.gradle` +- `docs/registries/env-keys.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` + +- [ ] RED settings/composition cases: + Model three disjoint states. `PURE_DISABLED` means canonical `disabled` plus legacy config absent + and has every notification runtime resource 0. `PRE_LEGACY_BRIDGE` means canonical `disabled` + plus exact legacy-only config in a structurally detected PRE artifact; canonical + provider/store/worker resources are 0 while the transitional operator/fence/permit and selected + legacy provider exist, with admission/call 0 before initialization. `CANONICAL_CONFIGURED` + requires exact binding-ID equality and no legacy key. Unknown/extra/missing + binding/property/provider/template/revision fails; expected-mode mismatch fails; exact send/store/ + ingress account-region-configuration-set-topic tuple plus retry/DLQ/max-callback-age/tombstone + profile; live/retained revision availability; + legacy+canonical conflict fails even when values agree. The bounded + `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS` key set must equal the compiled route catalog; + each value is the reviewed canonical target and its legacy predecessor is `target - 1`. A dark + legacy bridge may start with no fences but admits 0 until batch initialization creates the + complete predecessor set. During route-by-route switching, an exact key set may legally mix + `ACTIVE/LEGACY@predecessor` (legacy admits), `DRAINING/LEGACY@predecessor` (neither admits) and + `ACTIVE/CANONICAL@target` (canonical admits); each node opens only its owner-matching route. + `REQUIRE_CANONICAL` final startup requires every route at the exact canonical target. + `CUTOVER_WAIT` is permitted only in a PRE artifact with canonical-only config and the same + closed predecessor/target state machine. Each predecessor route has admission, dispatcher + claim and provider call 0 and readiness reports bounded `CUTOVER_WAIT`; it activates only + after a committed fence read proves `ACTIVE/CANONICAL@target`. + Before initialization, absent fences plus an empty persisted proof registry remain dark. Once + any initialization row exists, the snapshot's route/profile/admission-role/proof-class/ + evidence-revision/digest set must exactly equal `NotificationCutoverRouteCatalog`; partial, + extra or drifted registry state closes readiness and every writer. + Absent/partial/extra set, unrelated generation/owner/state, mixed legacy+canonical config in + one process or caller override fails startup/activation. Runtime drift from the allowed + predecessor/target state closes that route and readiness before any new work. + `NotificationCompositionValidator` derives `CUTOVER_WAIT` only from the PRE structural stage, + canonical-only graph and persisted exact fence state; no request, environment key or generic + Spring property may select it. +- [ ] RED zero-resource cases: + PURE_DISABLED -> provider/store worker/client/executor/scheduler/probe/readiness + component/operator/application DML·table scan 0; no secret lookup, cert fetch or provider + health call. Run this assertion after release-owned Flyway migrations; V7/V8 DDL validation is + not feature-gated application activity. An exact-empty database remains + `AWAITING_SIGNED_FRESH_PROVISIONING`, with application admission, workers and provider clients + dark until the separate provisioning operation commits. + PRE_LEGACY_BRIDGE -> canonical resources 0, only exact transitional + legacy resources present, + provider call 0 before audited initialization, then every legacy call requires a committed + permit. CANONICAL_CONFIGURED -> legacy/transitional admission path closed as dictated by the + ownership state. `NotificationZeroResourceTest`, `NotificationCompositionTest` and + `FencedLegacyNotificationPortTest` each cover all applicable states and reject every + same-process legacy+canonical overlap. +- [ ] RED secret bridge cases: + `SecretSource` resolves references only in bootstrap; adapter-owned handles receive versioned + material; missing/blank/malformed secret fails without logging it; no raw key or provider + credential is retained in settings, application values, entities or adapter-owned immutable + records. This is not an end-to-end wipeability claim: the current `SecretSource`/ + `EnvironmentSecretSource` API and JDBC credential APIs necessarily expose short-lived Java + `String` values that cannot be wiped. Only the adapter-facing mutable copy is wiped on close. + Tests prove no logging/exception/settings/entity retention and minimize/shorten source-to-handle + copies; they do not claim erasure of an already-created JVM `String`. +- [ ] RED same-DB topology cases against the canonical Spring infrastructure: + `SpringTransactionPort`, the primary `PlatformTransactionManager`, JPA + `EntityManagerFactory`/physical `DataSource` identity and notification store must resolve to + one topology; configured durable startup fails closed on any mismatch. Do not require or + manufacture a production “representative business repository”: this template intentionally + has no production sample aggregate, and `sample-portfolio` must not leak into the runtime + graph. Keep the real business-row write plus notification append commit/rollback proof in the + Task 10 app-bootstrap integration fixture. Every adopted feature later adds its own + composition contract proving its business store joins this primary transaction manager. +- [ ] RED the complete but inactive transitional cutover surface before any qualification or + deployment. The authenticated batch + `POST /api/admin/notifications/writer-ownership/initialize-legacy` accepts only the reviewed + initial-generation map keyed by the server-known routes, reason and operation token. The + proxied operation loads `NotificationWriterRouteSet` derived from the compiled cutover + catalog, derives the canonical ordered set and digest server-side, rejects missing/extra keys, + and maps only to + `InitializeNotificationWriterFencesOperation`. Neither route revisions outside that catalog + nor a caller-provided route-set digest is authoritative. The separate + `POST /api/admin/notifications/routes/{routeId}/writer-ownership` accepts only + `BEGIN_DRAIN|COMPLETE_SWITCH|ABORT_DRAIN` for one route and maps only to + `SwitchNotificationWriterOwnershipOperation`. BEGIN additionally requires a bounded signed + deployment inventory manifest; the controller maps only its opaque bytes/key revision, while + the application verifier independently authenticates exact environment/DB/route/PRE artifact + and complete node inventory and derives every row/count/digest. A third authenticated + `POST /api/admin/notifications/routes/{routeId}/writer-quiescence-attestations` maps only to + `RecordNotificationWriterQuiescenceAttestationOperation`; request data supplies reviewed drain + generation, a bounded signed quiescence manifest and token, never authoritative zero-fact/ + old-node/permit digests. The verifier authenticates the exact frozen node inventory, each + node's retired/quiesced fact plus irreversible deployment-generation/credential/egress fence, + consumer inventory/count 0 and provider-call-ledger identity/open-count 0. The server uses + post-lock DB time to validate the signer's bounded issued/expires window and record verified + time, snapshots the locked persisted proof registry, permit/holder set and BEGIN + inventory, and derives actor. The exact least-privilege + mapping + is `notification-operator -> notification:cutover,notification:cutover-terminalize, + notification:cutover-attest`; default + `admin` receives none. Controller tests cover 401/403, validation, actor spoof rejection + and DTO/command mapping; composition tests prove distinct interface-based method-security + initializer/terminalizer/switch/attestation proxies and internal-delegate separation. The + switch request + accepts `quiescenceAttestationToken` only for COMPLETE on a + `QUIESCENCE_REQUIRED` route; it is required there and forbidden for + BEGIN/ABORT/HARD_BOUND_PROVEN. Caller-supplied actor, profile/permit/holder/node-set or + zero-fact digests are never authoritative. + The fourth authenticated + `POST /api/admin/notifications/routes/{routeId}/writer-permits/terminalize-expired` maps only + to `TerminalizeExpiredNotificationWriterPermitsOperation`; request fields are exact drain + generation, bounded batch, reason and token. Server derives actor, DB time, persisted registry + and affected permit set. It requires `notification:cutover-terminalize`, has provider I/O 0 + and cannot be invoked through a scheduler or read query. +- [ ] RED/GREEN the Ed25519 verifier before controller composition. Freeze + domain-separated `writer-inventory-manifest-v1` and + `writer-quiescence-manifest-v1` length-prefixed canonical encodings with sorted bounded node/ + fact rows. Cover valid current/retiring issuer key revisions, non-canonical order/encoding, + duplicate/unknown fields, oversized node set, signature/algorithm/key downgrade, wrong + environment/DB/route/artifact/generation/ledger identity, expiry and one-byte mutation. Only + opaque digests may represent node/environment/ledger identity; raw hostnames, credentials and + human PII are rejected from retained evidence. The checked-in closed trust catalog pins allowed + issuer key IDs, public-key digests and current/retiring windows; request/env data cannot + introduce a new trust anchor, and resolved public-key material must match the pinned digest. + The reviewed signed profile/closed catalog, not environment input, pins + `allowedClockSkew` and `acceptanceMargin`; the TTL env value may only tighten the maximum + issuance window and can never relax + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`. + Persist the canonical signed payload and signature together with issuer/trust snapshot, + issued/expires/verified times and exact environment/DB/artifact/inventory/ledger identities. + Cover signed zero-node authority, write-time verification, persisted round-trip and startup + re-verification. SQL checks only immutable structure and digests; Java is the cryptographic + authority. Production configuration contains issuer public-key references only, never an + issuer private signing key. +- [ ] RED real-PostgreSQL + MockMvc initialization cases before this surface can be deployed: + exact physical commit before 2xx; commit failure/rollback never returns 2xx; concurrent + different-token initialization has one winner; same-token result-loss replay returns the + committed full route-set result; absent fences plus empty control/data-plane journals succeed, + while partial/existing fence sets, any-nonempty-journal and + route-set/digest/generation/token mismatch fail without mutation. Include a two-route fixture + proving all fences, operation children and every proof-registry row commit atomically, and + that replay returns the stored registry digest rather than refreshing from changed input. + These cases + live in `NotificationWriterOwnershipCommitAckContractTest`. The same test covers physical + commit/failure/result-loss replay and stale-generation rejection for + `BEGIN_DRAIN|TERMINALIZE_EXPIRED_PERMITS|COMPLETE_SWITCH|ABORT_DRAIN`, plus terminalizer and + attestation commit-before-2xx, signed inventory/quiescence issuer and set mismatch, 401/403, + idempotent replay and COMPLETE missing/stale/mismatched-token failures. It pauses an old bridge + after credential/client/connection acquisition but before provider I/O, commits COMPLETE from + an independent transaction, resumes the old bridge and proves provider I/O 0 because its + deployment generation, credential and egress route are irreversibly disabled. A fake + application port is not sufficient. +- [ ] Freeze this minimum env/property grammar before implementation: + + | env key | property/use | default/classification | required when | + | --- | --- | --- | --- | + | `APP_NOTIFICATION_EXPECTED_STATE` | `app.notification.expected-state` | `disabled`, public enum | always | + | `APP_NOTIFICATION_EXPECTED_BINDING_IDS` | exact binding ID CSV assertion | empty, public | configured | + | `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS` | exact `route-revision:canonical-target-generation` set assertion | empty, public bounded CSV | legacy bridge or configured canonical | + | `APP_NOTIFICATION_CUTOVER_ATTESTATION_TTL` | maximum signed-evidence issuance/acceptance window; never a committed irreversible-proof lease | `5m`, public upper bound only | PRE cutover surface | + | `APP_NOTIFICATION_CUTOVER_INVENTORY_ISSUER_KEY_REFS` | trusted Ed25519 inventory/quiescence verifier public-key SPKI refs; never signing keys | empty, public verification-material bounded CSV | PRE cutover surface | + | `APP_NOTIFICATION_DB_EXPECTED_RUNTIME_ROLE` | exact runtime database principal | `notification_runtime`, public fixed value | notification schema present | + | `APP_NOTIFICATION_DB_EXPECTED_MIGRATOR_ROLE` | exact Flyway owner principal | `notification_migrator`, public fixed value | Flyway enabled | + | `APP_NOTIFICATION_DB_RUNTIME_USERNAME_REF` | runtime nonowner username reference | null, sensitive-config | notification schema present | + | `APP_NOTIFICATION_DB_RUNTIME_PASSWORD_REF` | runtime nonowner password reference | null, sensitive-config | notification schema present | + | `APP_NOTIFICATION_DB_MIGRATOR_USERNAME_REF` | Flyway owner username reference | null, sensitive-config | Flyway enabled | + | `APP_NOTIFICATION_DB_MIGRATOR_PASSWORD_REF` | Flyway owner password reference | null, sensitive-config | Flyway enabled | + | `APP_NOTIFICATION_DISPATCH_BATCH_SIZE` | bounded worker batch | `20`, public positive `<=100` | durable binding | + | `APP_NOTIFICATION_DISPATCH_CONCURRENCY` | bounded worker concurrency | `4`, public positive | durable binding | + | `APP_NOTIFICATION_CLAIM_LEASE` | claim lease | `30s`, public bounded duration | durable binding | + | `APP_NOTIFICATION_ATTEMPT_TIMEOUT` | absolute provider attempt budget | `10s`, public bounded duration | any binding | + | `APP_NOTIFICATION_FINALIZE_GRACE` | post-attempt drain/finalize grace | `30s`, public bounded duration | durable binding | + | `APP_NOTIFICATION_RECEIPT_RECONCILE_INTERVAL` | orphan/reconcile cadence | `30s`, public bounded duration | receipt binding | + | `APP_NOTIFICATION_RETENTION_INTERVAL` | redaction/purge cadence | `1h`, public bounded duration | durable binding | + | `APP_NOTIFICATION_SLACK_WORKSPACE_REF` | exact workspace identity reference | null, sensitive-config | Slack binding | + | `APP_NOTIFICATION_SLACK_DESTINATION_REF` | reviewed destination reference | null, sensitive-config | Slack binding | + | `APP_NOTIFICATION_SLACK_TOKEN_REF` | bound reference to a `SecretSource` key | null, sensitive-config | Slack binding | + | `APP_NOTIFICATION_SLACK_BOT_TOKEN` | env-backed secret-source-only material for baseline ref | null, secret | referenced Slack key | + | `APP_NOTIFICATION_SES_REGION` | exact AWS region | null, public enum/region grammar | SES binding | + | `APP_NOTIFICATION_SES_EXPECTED_CREDENTIAL_SOURCE` | workload credential mode assertion | null, public enum | SES binding | + | `APP_NOTIFICATION_SES_FROM_IDENTITY_REF` | verified identity reference | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_SES_CONFIGURATION_SET` | exact event configuration set | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_SES_TOPIC_ARN` | exact feedback TopicArn | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_SES_DLQ_REF` | infrastructure DLQ identity | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_PAYLOAD_AEAD_CURRENT_KEY_REF` | bound current AEAD key reference/version | null, sensitive-config | durable binding | + | `APP_NOTIFICATION_PAYLOAD_AEAD_RETIRING_KEY_REFS` | bounded retiring AEAD ref CSV | empty, sensitive-config | retained old ciphertext | + | `APP_NOTIFICATION_LOOKUP_HMAC_CURRENT_KEY_REF` | bound current HMAC key reference/version | null, sensitive-config | durable/receipt binding | + | `APP_NOTIFICATION_LOOKUP_HMAC_RETIRING_KEY_REFS` | bounded retiring HMAC ref CSV | empty, sensitive-config | rotating aliases | + | `APP_NOTIFICATION_PAYLOAD_AEAD_KEY_V1` | secret-source-only AES-256-GCM material | null, secret | selected v1 ref | + | `APP_NOTIFICATION_LOOKUP_HMAC_KEY_V1` | secret-source-only HMAC root material | null, secret | selected v1 ref | + + Binding IDs, kind/mode assertions, route/template/card revisions and ordered provider targets + remain checked-in closed YAML/code catalog entries; do not accept an unrestricted env map that + can invent them. Adding key version v2 means an additive versioned env/secret registry row, + never overwriting v1 while retained rows reference it. +- [ ] Add all public/sensitive keys to `env-keys.yaml` and `.env` with safe blank/default examples. + Add token/AEAD/HMAC and sensitive identity rows to `secrets-classification.yaml`. + Optional notification secrets use an explicit `required_when` condition and are validated only + by `NotificationCompositionValidator` when the matching binding is compiled; disabled mode + performs no secret lookup. +- [ ] Bind only provider/identity/key `*_REF` values into immutable `NotificationSettings`; raw + token/AEAD/HMAC bytes must never be an `application.yml` placeholder or settings field. The + bridge calls `SecretSource.resolve(ref)` only after the canonical graph selects that + provider/key revision. Database runtime/migrator references bind exclusively to + `NotificationDatabaseRoleSettings` and the dedicated runtime/Flyway data-source wiring, never + to application commands/records or `NotificationSettings`. Provisioner references are absent + from the normal application and exist only in the Task 21 provisioning source set. +- [ ] Extend `verifyEnvKeys` narrowly: an `.env` key without an `application.yml` placeholder is legal + only when `secrets-classification.yaml` registers it as `secret-source-only` with a + `required_when` condition. Unknown/orphan public keys still fail. Add the secrets registry as a + task input and tests for allowed secret-source-only, misspelled secret, disabled zero-lookup and + ordinary orphan rejection. +- [ ] Update `SecretsClassificationRegistryTest` so unconditional secret rows still match + `SecretSourceValidator.REQUIRED_PROD_SECRETS` 1:1, while `required_when` rows are excluded from + that global list and are covered by exact notification composition tests. Do not make optional + Notification secrets globally required in prod. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationCompositionTest' \ + --tests '*NotificationZeroResourceTest' \ + --tests '*NotificationSecretMaterialBridgeTest' \ + --tests '*NotificationCanonicalSameTransactionCompositionTest' \ + --tests '*NotificationCanonicalWriterFenceSetCompositionTest' \ + --tests '*NotificationWriterActivationGateTest' \ + --tests '*NotificationInternalTrustContextCompositionTest' \ + --tests '*FencedLegacyNotificationPortTest' \ + --tests '*NotificationCutoverAuthorizationCompositionTest' \ + --tests '*NotificationWriterOwnershipCommitAckContractTest' \ + --tests '*NotificationSecretEnvContractTest' \ + --console=plain + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*NotificationWriterOwnershipControllerTest' --console=plain + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*Ed25519NotificationWriterInventoryEvidenceVerifierTest' --console=plain + ``` + +- [ ] Bind canonical settings once, derive minimal outbound/inbound/persistence profiles, and pass + application-owned provider/store/ingress capability descriptors to the pure application + compatibility validator. +- [ ] At startup, have `NotificationSameDataSourceTopologyValidator` inspect the canonical + transaction port, primary transaction-manager/JPA resource and notification-store topology + descriptor and reject durable mode unless they share the same physical transaction + manager/data-source identity. Feature-specific business-store atomicity remains an explicit + feature composition test, not a fabricated bootstrap bean. This composition-root invariant + must not introduce a new project dependency edge. +- [ ] Wire exactly the three non-interchangeable PostgreSQL principals + `notification_migrator`, `notification_runtime`, `notification_provisioner` and reject any + additional notification-scoped owner/member/grantee. `notification_migrator` owns the + notification schema and is used only by the dedicated Flyway data source; + `notification_runtime` is a nonowner used by the application transaction manager; the + `notification_provisioner` credential is not composed here and is available only to the + explicit Task 21 provisioning source set. Resolve migrator/runtime credential references + separately, assert `current_user` and ownership/grants at startup, and fail on shared + credentials, owner runtime, role inheritance or unexpected membership. Tests prove runtime + cannot run DDL, mutate retained evidence/provenance directly, use transitional functions or + consume sequences beyond its exact runtime grants, except that the exact PRE artifact grants + runtime only its explicitly enumerated transitional function `EXECUTE` surface and FINAL + revokes that entire surface. Flyway cannot be reached through an application bean. +- [ ] Before resolving either runtime or migrator credentials, require the Task 9 external + DB-admin/IaC bootstrap revision and exact role-inventory/ownership probe from + `docs/runbooks/notification-database-role-bootstrap.md`. Application startup never creates, + alters or grants role membership. Missing/mismatched bootstrap evidence, shared credentials, + unexpected membership or an additional notification-scoped principal fails before Flyway or + runtime table access. +- [ ] Update the existing executable startup seam, not a parallel notification-only migration path. + `MigrationStartupConfig` builds Flyway from the dedicated migrator data source; + `MigrationStartupRunner` asserts its connection `current_user=notification_migrator` before + `migrate()` and never receives the primary runtime data source; + `RequiredEnvironmentValidator` requires the runtime credential refs always and migrator refs + exactly when startup migration is enabled. Keep the DB URL/schema locations common, but make + the credential binding choice explicit: `spring.datasource.*` remains the + `notification_runtime` application data source, while `spring.flyway.user` and + `spring.flyway.password` are intentionally unbound/forbidden so Boot cannot treat secret refs + as credentials or fall back to the runtime principal. The dedicated Flyway data source gets + resolved username/password bytes only from `NotificationDatabaseRoleSettings`; tests fail if + Flyway is constructed from the primary data source, if either role ref aliases the other, or + if migration-on-startup can run without the migrator refs. +- [ ] Invoke `NotificationCanonicalWriterFenceGuard` from canonical intent admission inside the same + business-write/append transaction; configured bindings require an exact checked-in route/fence + generation. A stale generation or non-canonical owner rolls back both business write and intent + append and prevents worker activation. Bootstrap never calls the guard or + `NotificationCanonicalWriterFencePort` directly. +- [ ] Freeze the trust-context wiring matrix: + scheduler/worker/health, feature-internal notification orchestration and legacy bridge use + manually composed non-bean application delegates; verified SNS ingress uses a distinct manually + composed receipt delegate after signature/topology authentication; neither path is subjected to + JWT method security or exposed to a normal controller. Only the transitional human initializer + and cutover operations are registered as Spring method-security beans behind + `InitializeNotificationWriterFencesOperation`, + `TerminalizeExpiredNotificationWriterPermitsOperation`, + `RecordNotificationWriterQuiescenceAttestationOperation` and + `SwitchNotificationWriterOwnershipOperation`. Never reuse any proxied target as an internal + delegate, and never publish the internal delegates as Spring use-case beans. Context tests + assert bean absence/identity separation so schedulers/SNS do not fail for missing + `Authentication` and operator calls cannot bypass AOP. +- [ ] Build the legacy fence wrapper and full operator endpoint in this task, but keep them dark: + no automatic fence initialization, no provider call while the fence is absent, and no rollout + before Wave F qualifies this exact artifact. This ordering is deliberate: Task 19/20 manifests + may say `PRE_CUTOVER_BRIDGE` only when the compiled bridge/operator/permit surface being + deployed in 21A is already present. Task 21A/21B perform human-controlled state transitions + and rerun the same tests; they do not add or alter production code before cleanup. +- [ ] `NotificationSettings` is the only `@ConfigurationProperties` binder. Provider and worker + `*RuntimeProfile` types are plain immutable derived slices with no binding annotation or + independent defaults. +- [ ] `NotificationWriterActivationGate` calls only + `NotificationOperationsSnapshotUseCase` for bounded committed refreshes. It never injects + `NotificationOperationsSnapshotPort`, a repository, entity, `EntityManager` or JDBC type. + Snapshot freshness gates readiness/worker admission, while + `NotificationCanonicalWriterFenceGuard` inside each business-write/append transaction remains + the final authoritative fence. Tests prove stale cache cannot authorize an append and no route + opens before a fresh exact committed snapshot. +- [ ] Collect actual descriptors only from the three adapter-owned descriptor sources. Keep expected + settings as a separate input and add negative tests that mutate each actual source + independently; a validator that derives expected and actual from the same settings is + tautological and must fail review. +- [ ] Default `application.yml` to explicit `disabled`; no blank-is-disabled ambiguity. +- [ ] Add every new env key with type/default/secret classification and safe example; no secret value + in YAML/docs/tests. +- [ ] Verify GREEN with the same command and: + + ```bash + cd src && ./gradlew :adapter:inbound:web:check --console=plain + cd src && ./gradlew verifyEnvKeys \ + verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Acceptance claim: exact local composition and disabled resource safety; real provider state + remains unqualified. + +**Rollback checkpoint:** deploy canonical code/config dark. Never enable canonical and legacy for the +same route. Before `COMPLETE_SWITCH`, rollback begins by setting canonical expected-state disabled, +pausing workers and using `ABORT_DRAIN` only from DRAINING. After COMPLETE, use the forward-only +incident path in Task 21B; do not re-enable legacy. + +### Task 18: Add bounded workers, retention, observability and readiness truth + +**Owner:** `app-bootstrap` composition with adapter-owned operations +**Depends on:** Task 17 + +**Files — create:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDispatcherScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationReceiptReconcilerScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationRetentionScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationMetrics.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredNotificationIntentAppendPort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredInlineNotificationAttemptPort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredNotificationProviderAttemptPort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredNotificationReceiptStorePort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationHealthIndicator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationReadiness.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationLifecycleTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationObservabilityPrivacyTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationMeteredDecoratorTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationMaintenanceStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationRetentionIntegrationTest.java` + +- [ ] RED lifecycle cases: + bounded executor/queue; no unbounded scheduler overlap; graceful stop halts new claims then + drains bounded authorized attempts; stale lease recovery; readiness down without liveness + restart loop; disabled has zero threads. +- [ ] RED observability/privacy cases: + bounded tags only; no intent/recipient/provider message/tenant raw IDs; representative + PII/secret markers absent from log/span/metric/health/exception/DB plaintext; backlog and oldest + age expose only bounded route/card labels. Cover append, inline attempt, provider attempt + duration/outcome and receipt/orphan event counters, including synchronous inline and SNS paths. +- [ ] RED metering isolation cases: + each bootstrap-owned decorator delegates exactly once; timing/tag construction and meter + registry failures are swallowed into a bounded diagnostic and never change send/store/use-case + result, exception or transaction semantics. No Micrometer/bootstrap type may cross into + `application-core`, outbound notification, persistence or inbound web. +- [ ] RED retention/rotation cases: + payload redaction separated from dedupe tombstone; purge order honors FK; active/backlog/ + receipt window blocks key/template removal; old HMAC alias matches then upgrades to current; + indefinite suppression re-HMAC before ciphertext removal. SNS outer/semantic tombstones outlive + the checked-in max callback age plus ingestion safety margin, and semantic retention covers the + approved manual redrive horizon. Purge just before either bound fails; just after all bounds + and orphan/backup needs pass may succeed. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationLifecycleTest' \ + --tests '*NotificationObservabilityPrivacyTest' \ + --tests '*NotificationMeteredDecoratorTest' \ + --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationRetentionIntegrationTest' \ + --tests '*NotificationOperationsSnapshotIntegrationTest' \ + --console=plain + ``` + +- [ ] Implement scheduler beans only for compiled durable/receipt bindings; use bounded batch, + concurrency, retry and shutdown deadlines from reviewed settings caps. +- [ ] Schedulers invoke `NotificationDispatchUseCase`, receipt/reconciliation use cases and + `NotificationMaintenanceUseCase`; they never invoke repositories or persistence entities. +- [ ] Metrics, health and readiness obtain backlog/oldest-age/card facts only through + `NotificationOperationsSnapshotUseCase` and adapter-owned provider readiness probes. Bootstrap + never calls `NotificationOperationsSnapshotPort`, repositories or entities directly, and it + never calls Slack/AWS SDKs directly. +- [ ] Writer-cutover snapshot fields report ACTIVE permit count/max expiry by exact route and + legacy owner across every historical fence generation, plus + `TIMED_OUT_UNPROVEN` count/profile-set digest and only bounded attestation freshness/status + facts—never raw token/evidence. A timestamp-past ACTIVE row remains active until exact + token/version CAS; unproven timeout stays mechanically blocking without attestation. Tests + include an old-generation/multi-profile permit surviving `ABORT_DRAIN` and blocking the next + `COMPLETE_SWITCH`. +- [ ] Wrap application outbound ports only at the composition root with bootstrap-owned metered + decorators, following the existing `MeteredDistributedLockPort` pattern. Never author or wrap + an application inbound use case in bootstrap. Decorators observe only bounded application + result enums/card IDs and elapsed time; they neither own policy nor cause provider/store + retries. +- [ ] Complete the persistence binding matrix: + append/delivery store -> `NotificationStoreAdapter`, + receipt store -> `NotificationReceiptStoreAdapter`, + technical suppression -> `NotificationTechnicalSuppressionStoreAdapter`, + maintenance -> `NotificationMaintenanceStoreAdapter`, + safe operational projection -> `NotificationOperationsSnapshotAdapter`. +- [ ] Health/readiness must report exact card/profile and backlog state; fake/offline evidence cannot + make a provider card ready. +- [ ] Verify GREEN with the same commands. +- [ ] Acceptance claim: local operational safety/privacy evidence; sandbox/load evidence still + pending. + +**Rollback checkpoint:** pause new admission, stop schedulers, inventory in-flight/accepted/ +indeterminate rows, preserve all referenced revisions and prefer forward-fix. + +### Wave E exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :adapter:inbound:web:check \ + :adapter:outbound:persistence-jpa:check \ + :app-bootstrap:check \ + verifyEnvKeys \ + verifyPublicPathSnapshot \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Request inbound-security, configuration, operations and privacy review. +- [ ] Update the LLM Wiki branch-note with Wave E evidence and an explicit derived-document decision. + +--- + +## Wave F — Exact provider qualification + +### Task 19: Produce opt-in Slack/SES real-provider and SNS topology evidence + +**Owner:** `app-bootstrap` verification source sets (`:app-bootstrap`) +**Depends on:** Tasks 13–18 + +**Files — create:** + +- `src/app-bootstrap/src/notificationSlackReadiness/java/dev/caskeleton/bootstrap/notification/SlackNotificationReadinessTest.java` +- `src/app-bootstrap/src/notificationSesReadiness/java/dev/caskeleton/bootstrap/notification/SesNotificationReadinessTest.java` +- `src/app-bootstrap/src/notificationSesReadiness/java/dev/caskeleton/bootstrap/notification/SesSnsFeedbackReadinessTest.java` +- `src/app-bootstrap/src/notificationSesDlqReadiness/java/dev/caskeleton/bootstrap/notification/SesSnsDlqReadinessTest.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStage.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStageDetector.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationProductionEvidenceIssuerInput.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationProductionEvidenceIssuerClient.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStageDetectorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStageLegacyMarkerAllowlistTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationEvidenceManifestTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationProductionEvidenceIssuerClientTest.java` +- `src/app-bootstrap/src/test/resources/notification/evidence/notification-evidence-schema-v1.json` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `docs/registries/env-keys.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/adapter/outbound/notification/README.md` +- `src/adapter/outbound/notification/CLAUDE.md` + +- [ ] Register proposed tasks: + `notificationSlackReadiness`, `notificationSesReadiness` and the separately authorized + `notificationSesDlqReadiness`, plus a shared `notificationReadinessSupport` source set consumed + by every readiness/qualification lane. Do not register the final aggregator here and do not + attach any real-provider task to ordinary `test` or `check`. +- [ ] Have the build generate an immutable artifact-structure input from the compiled production + JAR class/resource inventory, reviewed production dependency locks, source digest and artifact + digest. `NotificationReleaseStageDetector` derives + `PRE_CUTOVER_BRIDGE|FINAL_CLEANUP` from that input only; no property, environment variable, + test argument or caller may override it. Both stages require the additive V7 journal schema; + historical table/column/migration names are not executable legacy markers. + `PRE_CUTOVER_BRIDGE` requires the executable legacy notifier, + `FencedLegacyNotificationPort`, PRE cutover catalog/route set, initializer/switch/permit/ + terminalizer/quiescence-attestation classes/beans/controller, all three operator permissions + and absence of the + final cleanup migration. `FINAL_CLEANUP` requires those executable + legacy/bridge/operator/permit/terminalizer/attestation/`CUTOVER_WAIT` + classes/beans/config/role mappings + absent, the retained canonical route catalog/route set, canonical fence guard/adapter, + canonical-only `REQUIRE_CANONICAL` config, retained V7 + proof-registry/permit/operation history plus canonical signed BEGIN-inventory header, + quiescence/attestation header, evidence trust snapshot, + `NotificationWriterFinalizationDiscriminatorEntity`/ + `NotificationWriterFinalizationDiscriminatorJpaRepository`, fresh-provenance schema resource + and the exact closed discriminator states + `AWAITING_SIGNED_FRESH_PROVISIONING|FRESH_PROVISIONED|UPGRADE_VALIDATED`, reviewed V8 + validation/awaiting state and cleanup migration present. The structural detector rejects an + absent/unknown discriminator, a fresh token plus validated-history digest overlap, and any + state/resource combination outside the Task 9 XOR. It also + requires the explicit post-migration `notificationFreshProvisioning` task contract and rejects + migration-time provisioning. + Partial, contradictory, unknown or digest-mismatched + inventories fail before a provider side effect. +- [ ] Keep the explicit legacy marker names only in + `NotificationReleaseStageDetector` and its two reviewed detector tests. Do not obfuscate names + with string concatenation. `NotificationReleaseStageLegacyMarkerAllowlistTest` scans the + repository, requires the complete expected marker set in the detector, and fails on any + occurrence outside the exact path/symbol allowlist. Production consumer-zero hygiene is a + separate scan over every registered production leaf's `src/**/src/main` tree plus production + config. +- [ ] Before any real provider lane, prepare the isolated sandbox by the detector-derived stage; + fixture SQL and caller stage override are forbidden: + + - `PRE_CUTOVER_BRIDGE`: deploy the exact PRE artifact with legacy-only config and admission + closed; invoke authenticated batch `INITIALIZE_LEGACY`; start canonical-only instances of the + same artifact in `CUTOVER_WAIT` with the reviewed future generation set and prove their + admission/worker/provider-call count is 0. For every route, call the independent infrastructure + issuer through `NotificationProductionEvidenceIssuerClient`; it signs the complete + environment/DB/route/artifact old-node set and that manifest is bound to `BEGIN_DRAIN`. + Omitted/extra node or permit holder fails. If expired ACTIVE permits remain, invoke the + authenticated bounded terminalizer until the read-only snapshot reports ACTIVE 0. Then select + exactly one PRE proof arm. On current R0, `QUIESCENCE` proves every frozen instance has an + irreversible deployment-generation tombstone and legacy credential/egress revocation, + consumer inventory 0 and provider-ledger open-count 0; the issuer signs that exact manifest + and the operation root-commits its attestation before COMPLETE. Reverify the retained signed + headers/trust snapshot in Java and prove a paused old process with cached + credential/client/connection performs provider I/O 0 after COMPLETE. `HARD_BOUND` also + Java-reverifies the retained signed BEGIN inventory header/children before COMPLETE, proves + the exact all-hard-bound registry/evidence revision and safe terminal permits, keeps ACTIVE 0, + and forbids quiescence attestation and its child facts. Verify latest + operation/attestation results and that only exact + `ACTIVE/CANONICAL@g_final` routes activate on the waiting instances. Capture + commit-before-2xx, wait-to-active evidence and the immutable cutover operation-history + digest including BEGIN inventory plus exactly the selected arm: attestation + permit/holder/node sets, irreversible fence identities and consumer/provider-ledger zero + snapshot for `QUIESCENCE`, or registry/evidence revision and safe terminal permits for + `HARD_BOUND`. + - `FINAL_CLEANUP`: deploy the exact cleanup artifact either on the already-canonical upgrade + sandbox whose V8 validation preserves the route set/history, or on a clean-provisioning + V1..V8 sandbox. V8 must leave the latter + `AWAITING_SIGNED_FRESH_PROVISIONING`; obtain an independent issuer-signed database-birth + authorization through the production issuer client only after its control plane has + durably committed and independently observed the permanent no-legacy-authority fence. The + authorization and retained row must include the complete Task 9 + `notification_fresh_installation_provenance` field set and causal order, not a summarized + subset: enforcement revision/digest/activation/read-back precedes cached DB-session and + provider-flow termination; the causally later post-enforcement zero manifest includes + provider-ledger cut revision/time plus entry/open/indeterminate counts 0; the permanent + fence canonical payload/digest is seal-committed, marked irreversible and read back before + signing. Require the exact credential issuance/revocation set digests and completion facts, + ingress/egress denial policy digests and established-flow blocks, + `legacy_database_session_inventory_digest`, + `legacy_database_session_open_count=0`, + `legacy_database_session_termination_evidence_digest`, + `legacy_provider_connection_flow_inventory_digest`, + `legacy_provider_connection_flow_open_count=0`, and + `legacy_provider_connection_flow_termination_evidence_digest`. The issuer refuses a + pre-enforcement zero snapshot, cross-revision composition, sign-before-seal, unsigned, + uncommitted or reversible fence. Invoke the opt-in + `notificationFreshProvisioning` Gradle/CLI operation. It commits signed provenance, + `INITIALIZE_CANONICAL_FRESH` and the exact route fence set in one transaction, after which a + restart may enter `REQUIRE_CANONICAL`. Prove an unsigned/expired/wrong + environment/DB/artifact/inventory/ledger authorization, a nonempty partial fence set and an + existing-history fresh marker all fail. Pause/inject an old deployment generation, cached + legacy DB/provider credential, established DB session/provider connection and legacy egress + path before provisioning; prove both exact session/connection inventories are open-count 0, + both termination evidences are valid and both established-flow block facts are true, and + legacy DB I/O and provider I/O are both 0 before and after provisioning, including after + resume. Prove canonical + catalog/config/fence exact equality and transitional endpoint/class/role 0. Do not reference + or invoke deleted initializer, switch, permit, terminalizer or attestation types. Capture the + cleanup migration/validated-fence-set digest. + + Missing/partial fences, mixed-stage markers, direct SQL, a PRE operation in FINAL or a + legacy/canonical config overlap makes the sandbox ineligible. +- [ ] Make each real lane fail closed when explicitly invoked without required exact credentials, + sandbox destination/account/region/workspace/configuration set/topic/DLQ inputs. Do not convert + absent configuration to JUnit success/skip. +- [ ] Require an already deployed, isolated sandbox topology; a Gradle process on localhost is never + considered reachable by SNS: + the same release source/artifact revision runs behind public HTTPS + `/webhooks/notifications/aws-ses-v1`; an SES configuration-set event destination targets the + exact SNS TopicArn; the HTTPS subscription is confirmed and has an explicit redrive policy to + the reviewed DLQ; its bounded SNS HTTP `DeliveryPolicy` fixes retry count, min/max delay, + backoff function and total retry horizon; the deployed service and readiness runner observe the + same sandbox PostgreSQL notification journal/application store. Validate subscription + ARN/status, TopicArn, configuration set, endpoint, delivery-policy digest/horizon, DLQ/redrive + policy/retention/redrive horizon, derived max callback age, ingress tombstone retention and + deployed revision before sending. Actual values must equal the checked-in ingress profile. +- [ ] Freeze required readiness inputs, all registered/classified without logging their values: + `APP_NOTIFICATION_READINESS_HTTPS_BASE_URL`, + `APP_NOTIFICATION_READINESS_DEPLOYED_REVISION`, + `APP_NOTIFICATION_READINESS_JOURNAL_DB_REF`, + `APP_NOTIFICATION_READINESS_SES_SUBSCRIPTION_ARN`, + `APP_NOTIFICATION_READINESS_SES_DELIVERY_POLICY_DIGEST`, + `APP_NOTIFICATION_READINESS_SES_DLQ_REF`, + `APP_NOTIFICATION_READINESS_MAX_WAIT`, and + `APP_NOTIFICATION_READINESS_DLQ_DRILL_ENABLED`, + `APP_NOTIFICATION_READINESS_EVIDENCE_ISSUER_ENDPOINT_REF`, + `APP_NOTIFICATION_READINESS_EVIDENCE_ISSUER_CLIENT_CREDENTIAL_REF` and + `APP_NOTIFICATION_READINESS_DB_BIRTH_AUTHORIZATION_REF`, plus + `APP_NOTIFICATION_READINESS_NO_LEGACY_AUTHORITY_FENCE_REF`. + `NotificationProductionEvidenceIssuerClient` submits only bounded + environment/DB/artifact/route/node/consumer/provider-ledger identities and resolves client-auth + references outside logs. No production source, configuration, environment key, test resource + or artifact may contain an evidence issuer private signing key. Provider + account/workspace/destination and + credential refs remain the exact Task 17 settings, not a second defaulting configuration tree. +- [ ] Slack lane: + send a bounded sandbox probe with both exact mode profiles; capture `(channel, ts)` and actual + request count; verify token/workspace/channel scope, hidden retry 0, no sensitive artifact. +- [ ] SES lane: + send exactly one simulator/verified sandbox recipient with `ca_attempt_v1`; observe `MessageId`; + receive an authentic SNS HTTPS callback at the deployed endpoint; poll the same sandbox journal + for the committed correlation/receipt projection; verify TopicArn/configuration-set/tag + matching and commit-before-ACK without recording recipient/content. Use a unique correlation, + bounded wait and cleanup only through `NotificationMaintenanceUseCase`. +- [ ] Keep the DLQ drill separate and human-approved. With + `APP_NOTIFICATION_READINESS_DLQ_DRILL_ENABLED=true`, use an isolated copy of the same artifact + and SNS subscription whose notification database is deliberately unavailable, publish a + bounded authentic SNS probe, observe application `503`, SNS retry and eventual movement to the + exact DLQ, then restore the sandbox and purge only the correlated probe. Task 16 remains the + deterministic proof of commit-failure-to-503; this lane proves the deployed SNS retry/redrive + topology. Require `READINESS_MAX_WAIT` to exceed the queried bounded retry horizon plus a + reviewed observation margin while remaining below the task-wide safety cap. It must never run + against a production subscription. +- [ ] Emit a sanitized provider manifest for each lane with the complete comparison axes: + card and binding revision; channel/mode/strategy/route; template/render/serialization/escaping + revision; submission/correlation/idempotency/reconciliation profile; receipt/projection + profile; credential-source generation; account/region/workspace digest; SES configuration set, + TopicArn, subscription ARN, exact delivery-policy digest/retry horizon, DLQ/redrive and ingress + profile, max callback age and tombstone/manual-redrive retention; persistence + schema/crypto profile; + writer route-set digest and exact canonical generation-set digest; a closed + stage-discriminated ownership evidence union: + `PRE.QUIESCENCE` is signed BEGIN inventory + signed quiescence/attestation header and trust + snapshot + exact irreversible tombstone/credential/egress facts + ACTIVE permit 0 + consumer + inventory 0 + provider-ledger 0 + cutover history; `PRE.HARD_BOUND` is signed BEGIN inventory + + the exact all-hard-bound registry/evidence revision + safe terminal permits + ACTIVE permit + 0 and forbids a quiescence attestation. `FINAL.FRESH` is V8/cleanup structural digest + + discriminator `FRESH_PROVISIONED` with its fresh token and null validated-history digest + + signed DB birth certificate + the complete exact Task 9 fresh-provenance field set and + enforcement-read-back → cached-session/flow termination → post-enforcement zero manifest + (provider-ledger entry/open/indeterminate 0) → irreversible fence seal/read-back → signature + causal order + signed fresh provenance/trust snapshot + `INITIALIZE_CANONICAL_FRESH`; + `FINAL.UPGRADE` is V8/cleanup + structural digest + discriminator `UPGRADE_VALIDATED` with its complete retained-history + digest, null fresh token and absent fresh provenance. `AWAITING_SIGNED_FRESH_PROVISIONING` + cannot emit readiness evidence. Source/dependency/artifact/deployed revision; actual + request count; lane/run timestamp/expiry. + Include immutable `release_stage`; the schema rejects unknown/missing/overlapping union arms, + evidence forbidden by the selected discriminator, detector/manifest stage mismatch and + sensitive raw values. +- [ ] Task 19 only produces real-provider/callback/DLQ evidence. It does not aggregate local + durability qualification and by itself does not authorize an operational R2 claim. +- [ ] RED then GREEN the evidence schema before any live side effect. Cover every required axis, + unknown/missing fields, sensitive raw value rejection, lane/card mismatch, expiry, skipped + evidence and release-stage mismatch. RED/GREEN the structural detector for exact PRE, exact + FINAL, mixed, unknown, caller-override attempts and artifact digest mismatch. Exact FINAL must + include retained V7 proof-registry/permit/operation history and signed + inventory/quiescence/attestation headers, trust snapshots, the actual finalization + discriminator entity/repository with its three exact states/XOR, and a fresh-provenance schema + resource whose row is required exactly for `FRESH_PROVISIONED` and forbidden for + `AWAITING_SIGNED_FRESH_PROVISIONING|UPGRADE_VALIDATED`, plus V8 + awaiting/validation and + cleanup migration while all executable + permit/terminalizer/cutover classes/beans/config are absent; + broad text scanning that classifies historical V7 as PRE must fail. PRE tests remove + each required terminalizer or attestation operation, endpoint, permission and schema marker in + turn and require mixed/unknown. FINAL tests require executable terminalizer/attestation/role 0 + while retained history remains and enumerate only + `FRESH_PROVISIONED|UPGRADE_VALIDATED`; PRE tests enumerate only + `QUIESCENCE|HARD_BOUND`. The production issuer-client test rejects a response not signed by the + pinned public trust snapshot and asserts that no private-key input is bindable: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationEvidenceManifestTest' \ + --tests '*NotificationReleaseStageDetectorTest' \ + --tests '*NotificationReleaseStageLegacyMarkerAllowlistTest' \ + --tests '*NotificationProductionEvidenceIssuerClientTest' \ + --console=plain + ``` + +- [ ] After registering the readiness source sets/tasks and all their dependencies, regenerate and + verify the exact app-bootstrap lock before resolving or executing any readiness task: + + ```bash + cd src && ./gradlew :app-bootstrap:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :app-bootstrap:verifyDependencyLocks --console=plain + ``` + +- [ ] Run only in an explicitly prepared sandbox: + + ```bash + cd src && ./gradlew :app-bootstrap:notificationSlackReadiness \ + --console=plain + cd src && ./gradlew :app-bootstrap:notificationSesReadiness \ + --console=plain + # Separate human-approved destructive sandbox drill only: + cd src && ./gradlew :app-bootstrap:notificationSesDlqReadiness \ + --console=plain + ``` + +- [ ] If these cannot run, record the exact blocker and keep the affected card + `NOT_QUALIFIED`; do not mark this Task complete. +- [ ] Acceptance claim: exact provider connectivity and callback/topology smoke evidence only; final + operational R2 eligibility is decided by Task 20 aggregation. + +**Rollback checkpoint:** readiness tests create external sandbox side effects. Use dedicated probe +destinations and retention cleanup; never run against arbitrary production recipients. + +### Task 20: Produce local qualification evidence and aggregate production readiness + +**Owner:** cross-leaf verification harness, aggregated by `app-bootstrap` +**Implementation depends on:** Task 18 +**Final aggregation depends on:** Task 19 provider manifests plus this task's local manifest + +**Files — create:** + +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationLoadQualificationTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationRotationQualificationTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationRollingRevisionContractTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationProcessCrashRecoveryTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationCrashScenarioMain.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationAttemptLedgerServer.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationQualificationOwnershipSetup.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/TestOnlyNotificationWriterEvidenceIssuer.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/TestOnlyNotificationWriterEvidenceIssuerTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationProductionReadinessAggregationTest.java` +- `docs/runbooks/notification.md` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `src/app-bootstrap/src/test/resources/notification/evidence/notification-evidence-schema-v1.json` + +- [ ] Define bounded steady/burst/throttle/callback/reconcile profiles and pass/fail thresholds before + running them. +- [ ] Make local qualification ownership setup stage-discriminated and fail closed: + + - `PRE_CUTOVER_BRIDGE` reaches canonical ownership only through the same application + root-transaction operations as Task 19: exact batch initialization, canonical-only + `CUTOVER_WAIT` instances with call/claim 0, route-specific BEGIN with trusted signed complete + node inventory, invoking the authenticated bounded terminalizer for expired ACTIVE permits + before ACTIVE 0 is claimed, and then exactly one proof arm. `QUIESCENCE` adds signed + node-retirement/credential/egress facts, consumer/ledger zero, attestation commit and stale-node + provider I/O 0. `HARD_BOUND` Java-reverifies the same retained signed BEGIN inventory, adds + only the all-hard-bound registry/evidence revision and safe terminal permits, and forbids + attestation. COMPLETE uses the selected exact proof and then + proves exact-generation activation. + - `FINAL_CLEANUP` uses either an exact validated canonical upgrade fixture or runs V1..V8 to + `AWAITING_SIGNED_FRESH_PROVISIONING` and then invokes the explicit + `notificationFreshProvisioning` operation with a signed database-birth authorization issued + only after the permanent irreversible no-legacy-authority fence is committed. The fresh + fixture must finish with discriminator `FRESH_PROVISIONED`, fresh token/provenance present, + validated-history digest absent, and retain the birth certificate plus the complete exact + Task 9 `notification_fresh_installation_provenance` field set and causal order, including + enforcement activation/read-back, cached-session/flow termination, the later + post-enforcement zero manifest, provider-ledger entry/open/indeterminate counts 0, and + irreversible fence seal/read-back before signature. The upgrade fixture must + finish with `UPGRADE_VALIDATED`, the complete retained-history digest present, fresh token and + provenance absent. `VerifyRetainedNotificationWriterEvidenceUseCase`, never a bootstrap + repository/entity read, verifies the selected arm from the actual + `NotificationWriterFinalizationDiscriminatorEntity` and bounded persistence read adapter; a + manifest-only enum is not evidence. Unsigned/uncommitted/reversible-fence, + provenance-less or ambiguous empty fixtures fail. Its qualification source must compile + and run after all + initializer/switch/permit/terminalizer/attestation types are deleted and must prove those + types/endpoints are absent. + + Both paths require route-set/generation-set plus the stage-specific ownership evidence digest + to match the selected release profile. Direct fence seed SQL outside Flyway, mixed markers, + PRE operation calls in FINAL and stage override all fail. +- [ ] Keep `TestOnlyNotificationWriterEvidenceIssuer` only in the + `notificationQualification` source set. It uses a deterministic local test key and signs the + same canonical inventory, quiescence and DB-birth authorization payloads for repeatable tests; + its manifests are explicitly `LOCAL_TEST`, have a lower evidence grade and cannot satisfy + Task 19 or `notificationProductionReadiness`. Production JAR/config/source sets contain + neither this class nor its private key. The final aggregator accepts production ownership + evidence only from the Task 19 independent issuer client. +- [ ] RED/qualification matrix includes: + claim/finalize contention; provider throttle; callback burst; response loss; DB finalize + outage; key/credential/template rotation; old/new writer HMAC aliases; rolling worker revisions; + park/resume restart; retention/redaction; cancellation/expiry racing wire authorization. +- [ ] Add an actual forked-JVM crash harness, distinct from Task 12's deterministic fault injection. + The parent owns PostgreSQL plus `NotificationAttemptLedgerServer`; each child reports a durable + phase marker and calls `Runtime.halt(91)` at the requested point. A fresh child then runs + recovery while the parent asserts journal state and physical request count. Cover claim, + reserve, immediately before/after committed `WIRE_AUTHORIZED`, possible provider write, + response and finalize; include authorization commit failure and commit-success/result-loss. + No in-process exception may be accepted as process-crash evidence. +- [ ] At every post-authorization ambiguous point, assert request count is at most the exact + card-specific bound, there is no blind retry/fallback, and recovery ends only in an exact + terminal fact, provider reconciliation, or explicit `INDETERMINATE`. +- [ ] Extend the manifest schema/test for the local qualification and final aggregate rows, then run + it GREEN before registering/executing qualification. Every local/provider/DLQ/aggregate row + carries the detector-derived immutable `release_stage`, and the aggregator requires all input + rows to match its own artifact stage. Model ownership evidence as a closed discriminated union: + `PRE.QUIESCENCE` requires only signed BEGIN inventory, signed + quiescence/attestation header + trust snapshot, irreversible node/credential/egress facts, + ACTIVE 0, consumer/ledger 0 and cutover history; `PRE.HARD_BOUND` requires only signed BEGIN + inventory, the all-hard-bound registry/evidence revision, safe terminal permits and ACTIVE 0, + and forbids the attestation. `FINAL.FRESH` requires only cleanup/V8 structural evidence, + discriminator `FRESH_PROVISIONED` with fresh token and null validated-history digest, signed DB + birth certificate, the complete exact Task 9 fresh-provenance field set and + enforcement-read-back → cached-session/flow termination → post-enforcement zero manifest + (provider-ledger entry/open/indeterminate 0) → irreversible fence seal/read-back → signature + causal order, signed fresh provenance/trust snapshot and `INITIALIZE_CANONICAL_FRESH`; + `FINAL.UPGRADE` requires only + cleanup/V8 structural evidence, discriminator `UPGRADE_VALIDATED` with complete retained-history + digest and null fresh token, and forbids fresh provenance. Awaiting, missing, overlapping or + cross-stage/arm evidence fails: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationEvidenceManifestTest' \ + --tests '*NotificationReleaseStageDetectorTest' \ + --console=plain + ``` + +- [ ] First register `notificationQualification` and + `notificationProductionReadiness` in `src/app-bootstrap/build.gradle`. Keep long load/rotation/ + crash drills out of ordinary `test`/`check`. The first task emits a fresh local qualification + manifest; the second is only an aggregator and performs no provider send. +- [ ] After source-set/task/dependency registration and before executing either task, regenerate and + verify strict lock state: + + ```bash + cd src && ./gradlew :app-bootstrap:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :app-bootstrap:verifyDependencyLocks --console=plain + ``` + +- [ ] Run deterministic local qualification: + + ```bash + cd src && ./gradlew :app-bootstrap:notificationQualification --console=plain + ``` + +- [ ] Run real-provider portions only through Task 19 lanes; never embed live credentials/network in + these ordinary tests. +- [ ] Aggregate only fresh, schema-valid manifests for the release-selected exact card set: + the local qualification manifest plus each required Task 19 provider/callback/DLQ manifest. + Compare every frozen axis, source/dependency/artifact/deployed revision and expiry. Missing, + stale, extra, mismatched or skipped evidence fails `notificationProductionReadiness` with + `NOT_QUALIFIED`; never infer evidence from a passing unit test. +- [ ] After both evidence producers have run, execute: + + ```bash + cd src && ./gradlew :app-bootstrap:notificationProductionReadiness \ + --console=plain + ``` + +- [ ] Write runbook actions for backlog, indeterminate, bounce/complaint, provider outage, + credential/key/template rotation, SNS retry/DLQ and route pause/resume. +- [ ] Preserve the explicit no-exactly-once claim and card-specific duplicate risk. +- [ ] Require the structural detector to emit `PRE_CUTOVER_BRIDGE` for every Wave F manifest from the + exact Task 17 bridge artifact; never hardcode that label in a task or test fixture. Aggregator + success makes only that exact bridge artifact eligible for the bounded canary/switch decision; + it is not final R2 evidence because Task 21C changes production source, configuration, + dependencies, release stage and artifact revision. + +**Rollback checkpoint:** qualification itself does not authorize production rollout. Rollout remains +route-specific and human-controlled. + +### Wave F exit gate + +- [ ] Record the exact local/provider manifest digests, expiry, source/artifact revision and every + skipped/not-run lane. Never copy evidence between cards or environments. +- [ ] Request independent durability, provider-protocol, ingress-security and operations review. +- [ ] Update the LLM Wiki branch-note with Wave F evidence and an explicit derived-document decision. + +--- + +## Wave G — Canonical cutover, legacy removal and final verification + +### Task 21: Cut over the canonical graph and remove the R0 legacy path + +**Owner leaves:** application, notification, persistence-jpa, inbound-web, bootstrap +**Depends on:** Tasks 17–20 and route-specific human cutover decision + +**Delete only after `rg` proves production consumer 0 and canonical tests are GREEN:** + +- `src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortContractTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationRoutesSettings.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterQuiescenceAttestationAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterCutoverIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterQuiescenceAttestationIntegrationTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPort.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPortTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCutoverAuthorizationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationWriterOwnershipCommitAckContractTest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipResponse.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipControllerTest.java` + +**Modify:** + +- `src/.env` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/DisabledAdapterSentinelTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterStartupMode.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGate.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleComposition.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleTopologyValidator.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationZeroResourceTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationInternalTrustContextCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGateTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCanonicalWriterFenceSetCompositionTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationQualificationOwnershipSetup.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfEmailNotificationConfigured.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfSlackNotificationConfigured.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `src/sample-portfolio/src/main/resources/application.yml` +- `src/adapter/outbound/support/src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationRouteWriterPermitJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationRouteJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterTransportProofRegistryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceAttestationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterInventoryManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterDrainNodeInventoryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceNodeEvidenceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterEvidenceTrustSnapshotJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationFreshInstallationProvenanceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterFinalizationDiscriminatorJpaRepository.java` +- `docs/registries/env-keys.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/README.md` +- all affected README/CLAUDE files and the deep design implementation-status section + +**Transitional lifecycle inventory — create in Task 17, qualify in Wave F, then remove after +observation:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPort.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPortTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationWriterOwnershipCommitAckContractTest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipResponse.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipControllerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCutoverAuthorizationCompositionTest.java` + +**Create for 21C fresh canonical installation:** + +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceQuery.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceSnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceQueryPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceVerifierPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceVerification.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/VerifyRetainedNotificationWriterEvidenceUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/VerifyRetainedNotificationWriterEvidenceUseCaseTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFreshProvisioningAuthorization.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationFreshProvisioningAuthorization.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFreshProvisioningAuthorizationVerifierPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFreshProvisioningPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesUseCaseTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provisioning/Ed25519NotificationFreshProvisioningAuthorizationVerifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provisioning/NotificationFreshProvisioningTrustCatalog.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provisioning/Ed25519NotificationFreshProvisioningAuthorizationVerifierTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/evidence/Ed25519NotificationRetainedWriterEvidenceVerifier.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/evidence/Ed25519NotificationRetainedWriterEvidenceVerifierTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__validate_or_prepare_canonical_notification_writer_fence.sql` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/evidence/PostgreSqlNotificationRetainedWriterEvidenceQueryAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/evidence/PostgreSqlNotificationRetainedWriterEvidenceQueryAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationFreshProvisioningAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationProvisionerTransactionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationFreshProvisioningIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationProvisionerTransactionAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationCanonicalFenceInitializationMigrationTest.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningCli.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningComposition.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningSettings.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationProvisionerDataSourceConfig.java` +- `src/app-bootstrap/src/notificationFreshProvisioningTest/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningCliIntegrationTest.java` +- `src/app-bootstrap/src/notificationFreshProvisioningTest/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningSettingsTest.java` +- `src/app-bootstrap/src/notificationFreshProvisioningTest/java/dev/caskeleton/bootstrap/notification/NotificationProvisionerTransactionCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationRetainedWriterEvidenceCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationRetainedWriterEvidenceStartupTest.java` + +- [ ] Before deletion, run: + + ```bash + rg --hidden -n \ + 'NotificationPort|RoutingNotifier|slack-webhook|google-email|app\.notification\.routes|APP_NOTIFICATION_(SLACK|EMAIL)_PROVIDER|APP_NOTIFICATION_SLACK_WEBHOOK_URL' \ + src docs/registries -g '!**/build/**' -g '!**/.git/**' + ``` + +#### Task 21A — bridge release, no deletion + +- [ ] Freeze and test this per-node/per-database truth table. A single process may never contain both + legacy and canonical keys; rolling nodes may temporarily use different rows only because the + shared database owner/generation makes one side fail closed: + + | phase/node config | legacy keys | canonical expected state/binding | DB owner | legacy admits | canonical admits | + | --- | --- | --- | --- | --- | --- | + | 21A before audited initialization | exact legacy-only | `disabled` / none | absent | no | no | + | 21A bridge | exact legacy-only | `disabled` / none | `LEGACY@g` | yes, with committed permit | no | + | 21B canonical-ready node before switch | absent | `configured` / derived `CUTOVER_WAIT`, exact future set | `LEGACY@g` | no | no | + | 21B old bridge node after switch | exact legacy-only | `disabled` / none | route set `CANONICAL@g_final` | no | no | + | 21B canonical node after switch | absent | `configured` / exact `g_final` set | route set `CANONICAL@g_final` | no | yes | + | 21C cleanup | absent/unknown | canonical-only exact `g_final` set | route set `CANONICAL@g_final` | path absent | yes | + | 21C exact-empty after V8 | absent/unknown | `AWAITING_SIGNED_FRESH_PROVISIONING` | absent | path absent | no | + | 21C after signed fresh provisioning | absent/unknown | canonical-only reviewed initial set | provenance-bound route set `CANONICAL@initial` | path absent | yes | + + Rows are evaluated per route. During 21B, the exact route key set may contain a reviewed mix + of `LEGACY@target-1`, `DRAINING@target-1` and `CANONICAL@target`; owner-mismatched nodes reject + that route without preventing other routes from continuing. + Any same-process legacy+canonical combination fails startup. After 21C every legacy key is + unknown and fails startup. +- [ ] Before bridge admission opens, deploy the exact Wave F-qualified + `PRE_CUTOVER_BRIDGE` artifact, which already contains the inactive transitional operator + controller and exact least-privilege mapping + `notification-operator -> notification:cutover,notification:cutover-terminalize, + notification:cutover-attest`; default `admin` + inherits none. Its + authenticated `INITIALIZE_LEGACY` action derives actor from + `AuthenticatedPrincipal` and calls only the method-security-proxied + `InitializeNotificationWriterFencesOperation`. A human supplies the reviewed initial + predecessor generations (`configured canonical target - 1`) for the server-disclosed exact + route set, reason and operation token; the + operation derives the ordered route set/digest from the compiled catalog. The root + transaction inserts the entire `ACTIVE/LEGACY@predecessor` set only for absent fences + empty + control/data-plane journals and atomically freezes the exact current+retiring proof registry, + is idempotent for the same token/set/registry, fails on + partial/mismatched/nonempty state, and reports success only after physical commit. Direct SQL, + sequential per-route or automatic bootstrap initialization is forbidden. +- [ ] Re-run the already implemented Task 17 `FencedLegacyNotificationPort` tests without changing + the qualified source/artifact. The wrapper calls + `NotificationLegacyWriterPermitUseCase` to reject ambient transactions and root-commit a + bounded permit before provider I/O, then root-commit release afterward. Acquire commit failure + means provider call 0; release failure leaves the lease visible and blocks switch until guarded + recovery/expiry. Canonical guard failure rolls back business state and intent append together. +- [ ] A permit expiry becomes `EXPIRED_PROVEN` only when the catalog transport profile proves an + acquire-committed DB-time absolute wire deadline, network-start refusal after it, connection + close/cancellation by it and + `wire deadline + finalize margin <= permit expiry` in an integration evidence revision, + including acquire-commit→process-pause→expiry→resume call 0. Current R0 is + `QUIESCENCE_REQUIRED`, so timeout becomes `TIMED_OUT_UNPROVEN`; stop 21B until a post-BEGIN + authenticated attestation verifies a trusted signed manifest over the exact BEGIN-frozen + complete old-node inventory, per-node irreversible retirement/credential/egress fencing, + consumer inventory/count 0, + provider-call-ledger identity/open-count 0 and the server-derived persisted-registry/ + TIMED_OUT_UNPROVEN/holder sets. COMPLETE mechanically + requires that token even when the set is empty; never infer call completion from TTL or a + caller digest/runbook checkbox alone. COMPLETE instead requires the already accepted signed + retained header plus exact irreversible deployment-generation/credential/egress facts, + ACTIVE 0 and provider-ledger 0. A crashed/release-failed ACTIVE permit is changed only after BEGIN by + the authenticated `notification:cutover-terminalize` operation; repeat bounded batches until + the read-only snapshot reports ACTIVE 0. Neither snapshot nor COMPLETE performs this mutation. +- [ ] Run bridge RED/GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*NotificationCanonicalWriterFenceGuardTest' \ + --tests '*InitializeNotificationWriterFencesUseCaseTest' \ + --tests '*NotificationLegacyWriterPermitUseCaseTest' \ + --tests '*TerminalizeExpiredNotificationWriterPermitsUseCaseTest' \ + --tests '*RecordNotificationWriterQuiescenceAttestationUseCaseTest' \ + --tests '*SwitchNotificationWriterOwnershipUseCaseTest' \ + --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationCanonicalWriterFenceIntegrationTest' \ + --tests '*NotificationWriterCutoverIntegrationTest' \ + --tests '*NotificationWriterQuiescenceAttestationIntegrationTest' \ + --tests '*NotificationWriterIrreversibleFenceIntegrationTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*FencedLegacyNotificationPortTest' \ + --tests '*NotificationCutoverAuthorizationCompositionTest' \ + --tests '*NotificationDatabaseRoleCompositionTest' \ + --tests '*NotificationFlywayRoleIsolationTest' \ + --tests '*MigrationStartupRunnerTest' \ + --tests '*RequiredEnvironmentValidatorTest' \ + --tests '*FlywayMigrationCompatibilityContractTest' \ + --tests '*NotificationWriterOwnershipCommitAckContractTest' \ + --console=plain + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*NotificationWriterOwnershipControllerTest' --console=plain + ``` + +- [ ] Human gate: after the qualified artifact and operator endpoint are deployed dark, execute the + audited `INITIALIZE_LEGACY` action where the fence is absent; then open bridge admission on + every old/new node, keep `LEGACY@g`, and prove no pre-bridge node remains before continuing. + The initialization cases above must be GREEN before this mutation. + Canonical code stays dark; do not delete any legacy code/config in 21A. Any code/config/lock + change after Wave F invalidates its manifest and requires all Task 19/20 PRE lanes to rerun + before deployment. + +#### Task 21B — route-specific ownership switch and observation + +- [ ] Reuse the Task 17/Wave F-qualified authenticated + batch initialization, expired-permit terminalization, route ownership and route + quiescence-attestation endpoints; do not add or extend production code between PRE + qualification and this switch. None is a public path. + Existing JWT/method-security enforcement requires `notification:cutover` for initialization + and switch, `notification:cutover-terminalize` for terminalization, and + `notification:cutover-attest` for attestation. The thin controller maps the + reviewed generation map/reason/token only to `InitializeNotificationWriterFencesOperation`; + that operation, not request data, supplies the compiled exact route set/digest. The controller + validates exact route/action (`BEGIN_DRAIN|COMPLETE_SWITCH|ABORT_DRAIN`), expected + generation, reason and operation token before mapping switch actions only to + `SwitchNotificationWriterOwnershipOperation`. BEGIN also maps a bounded signed inventory + manifest, never a caller-authored node digest; the application verifier derives and freezes the + exact server-trusted set. It derives the audited actor from + `AuthenticatedPrincipal`, never request data; target/expected owner is not a request field. + Direct SQL/repository access and bootstrap + handlers are forbidden. COMPLETE on a `QUIESCENCE_REQUIRED` route must carry the exact + `quiescenceAttestationToken`; the attestation endpoint maps a signed quiescence manifest, and + other actions/profiles reject those fields. +- [ ] Reconfirm the exact 21A least-privilege role mapping and the already registered distinct + interface-based initializer, terminalizer, attestation and switch targets as method-security + proxied Spring + beans; + the controller has no duplicate permission annotation and cannot obtain the manual internal + delegate. `NotificationCutoverAuthorizationCompositionTest` proves proxy creation, authorized + initializer/terminalizer/attestation/switch success, admin/missing-role 403, internal no-auth + delegates + still work, and + no final-class/proxy startup failure. Controller tests cover unauthenticated 401, validation, + actor spoof rejection and DTO/command mapping only: + + ```bash + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*NotificationWriterOwnershipControllerTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationCutoverAuthorizationCompositionTest' --console=plain + ``` + +- [ ] Re-run the Task 17 app-bootstrap real-PostgreSQL + MockMvc + `NotificationWriterOwnershipCommitAckContractTest`. Prove `BEGIN_DRAIN`, + `TERMINALIZE_EXPIRED_PERMITS`, `COMPLETE_SWITCH`, `ABORT_DRAIN` and `INITIALIZE_LEGACY` commit + failure/rollback never return + 2xx; success response is written only after physical root commit; stale generation conflicts; + and commit-success/result-loss replay with the same operation token is idempotent. Also prove + terminalizer commit/replay/401/403, signed inventory/attestation commit/replay/401/403, + omitted/extra node or permit holder and COMPLETE missing/stale/wrong + token/digest/profile-set failures before 2xx. Pause an old node with cached + credential/client/connection before provider I/O, commit COMPLETE from another transaction, + resume the old node and prove provider I/O 0 under revoked deployment generation, credential + and egress. The inbound leaf never imports persistence to make this claim: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationWriterOwnershipCommitAckContractTest' --console=plain + ``` + +- [ ] Roll canonical-only config nodes while the shared owner remains `LEGACY@g`; PRE composition + derives `CUTOVER_WAIT`. Those nodes are liveness-healthy but readiness reports + `CUTOVER_WAIT`, and their admission/claim/provider-call counts remain 0 while bridge nodes may + still acquire legacy permits. No node has both config grammars. +- [ ] After canonical-ready nodes are liveness-healthy in `CUTOVER_WAIT`, a human first invokes + `BEGIN_DRAIN` with an independently issued short-lived manifest of the complete + environment/DB/route/artifact old-writer node set. The root transaction verifies its signature, + rejects any known permit holder omitted from the inventory, freezes every node row/count/digest, + and closes new legacy permit acquisition. Poll active permit count/max expiry through + `NotificationOperationsSnapshotUseCase` outside a transaction. The count includes ACTIVE + legacy permits for the route across all old/current fence generations and does not ignore a + row merely because `expires_at` passed; only an exact terminal CAS removes it from the count. + For expired ACTIVE rows, call the authenticated terminalizer in bounded batches. It checks the + exact DRAINING generation and persisted registry, records the affected immutable set in the + operation journal and commits before 2xx; poll again until ACTIVE 0. + For the current `QUIESCENCE_REQUIRED` R0 profile, only after that committed BEGIN and ACTIVE + count 0, obtain an independently signed manifest that lists the exact frozen node set, every + node's retired/quiesced fact plus deployment-generation tombstone and legacy + credential/egress revocation, production consumer inventory/count 0 and provider-call-ledger + identity/open-count 0. Call the authenticated quiescence-attestation endpoint with that opaque + manifest. Its root transaction verifies the issuer/environment/DB/artifact/generation, derives + the catalog-equality-checked persisted profile set and locks/snapshots every-generation + `TIMED_OUT_UNPROVEN` `(token,generation,profile,state,rowVersion)` plus distinct permit-holder + sets, then requires exact node equality/holder subset and commits the derived evidence. + Invoke `COMPLETE_SWITCH` with that exact attestation token; it locks and recomputes the same + sets and rejects missing/mismatched evidence, reversible or absent + tombstone/credential/egress facts, a changed consumer/provider-ledger identity, nonzero ledger + state or any ACTIVE permit. Before either COMPLETE arm, Java reverifies the retained signed + BEGIN inventory canonical payload, signature, issuer public verification material, trust + snapshot, issued/expires/verified acceptance and header/child semantic equality. + QUIESCENCE_REQUIRED additionally reverifies the retained signed attestation bundle. Expiry + controls admission of new signed evidence, while accepted irreversible facts remain durable. + For a genuinely `HARD_BOUND_PROVEN` profile, every permit must instead be + `RELEASED|EXPIRED_PROVEN`, the COMPLETE request forbids an attestation token, and the valid + retained signed BEGIN remains mandatory. + After the applicable proof, invoke `COMPLETE_SWITCH`; its + `inRootWrite` CAS to route-specific `CANONICAL@g_final/ACTIVE` must physically commit before + success is reported. Missing durable evidence or a stale-node resume that can reach a provider + leaves the route DRAINING and makes the rollout `NOT_QUALIFIED`. `ABORT_DRAIN` is the only + rollback operation and emits a new LEGACY + generation, so one or more aborts make `g_final` greater than the naïve `g+1`; none of these + operations waits or sleeps inside the use case. Any abort makes the prior expected-generation + profile and PRE manifests stale. Update + `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS`, reproduce the resulting generation set through + the same audited sandbox operations, and rerun all required Task 19/20 PRE lanes before the + next expansion/switch decision. +- [ ] On each committed `COMPLETE_SWITCH`, `NotificationWriterActivationGate` opens only the matching + route on canonical `CUTOVER_WAIT` nodes after a fresh committed read; its notification + readiness becomes ready only when every configured route is at its exact target. Old bridge + nodes observe the canonical owner and keep that route closed. No restart/config watcher may + infer activation before the database fact. +- [ ] Define observation abort thresholds before the switch: any duplicate occurrence, any new + unexpected `INDETERMINATE`, oldest-backlog age over the route SLO, receipt lag over the + callback SLO, non-zero unplanned DLQ depth, or parked-gate count above the reviewed bound pauses + admission and aborts expansion. Do not automatically resend while diagnosing. +- [ ] Keep both code paths packaged during the reviewed observation window, but treat a committed + `COMPLETE_SWITCH` as forward-only. `ABORT_DRAIN` is valid only while the route is + `DRAINING/LEGACY`; there is no CANONICAL→LEGACY CAS or legacy re-enable after COMPLETE. + On a post-COMPLETE threshold breach, close canonical admission and workers through the shared + gate, inventory accepted/indeterminate/in-flight work, avoid replay and forward-fix. Any future + reverse handoff requires a separate canonical-drain/backlog/provider-result protocol, + duplicate policy, design approval and provider requalification. + +#### Task 21C — cleanup release + +- [ ] Before reserving `V8`, rescan every Flyway location; if occupied, use the next global version + and update the plan first. V8 is validation/preparation only and has two closed outcomes: + + - `UPGRADE_VALIDATED`: V8 establishes the Task 9 singleton discriminator in this exact state with a + server-canonical digest of the complete validated retained history, null fresh token and + absent signed fresh provenance. V8 inserts no fence or cutover history and preserves the + existing canonical fence/history/evidence rows byte-for-byte. The persisted route key set + exactly matches the reviewed set; every fence is + `ACTIVE/CANONICAL@g_final`, its latest pointer resolves to a matching committed + `COMPLETE_SWITCH`, and the complete legacy initialization→switch history passes the + structural validation below. A route with no complete history fails even when notification + data happens to be empty. + - `AWAITING_SIGNED_FRESH_PROVISIONING`: only an exact-empty V1..V8 notification + data/control/fence/history/evidence inventory may reach this state. V8 inserts no canonical + fence, provenance or initialization operation and writes a discriminator having neither + fresh token nor validated-history digest. Any nonempty inventory with a missing, partial or + noncanonical fence set fails; an awaiting marker on existing history is tampering. + Application admission, claim, worker and provider resources remain dark. + + Fresh initialization is an explicit post-migration operation, never Flyway lifecycle work. + Register the opt-in `:app-bootstrap:notificationFreshProvisioning` Gradle task backed by + `NotificationFreshProvisioningCli`; `NotificationFreshProvisioningSettings` binds only the + narrow authorization/public-key/provisioner refs listed below, and the CLI composes + `ProvisionFreshNotificationWriterFencesOperation` and the PostgreSQL implementation only in + the dedicated source set and is not attached to `test`, `check`, application startup or + Flyway. Register a separate `notificationFreshProvisioningTest` source set/task for its CLI + integration tests; that test task also stays out of ordinary `test`/`check`. The independent + infrastructure issuer signs a domain-separated + `notification-fresh-provisioning-v1` canonical payload binding authorization nonce/token, + environment, a database birth certificate with DB-system/database/schema identity and birth + token/revision/digest/`committedAt`, final source/artifact digest, exact canonical + route/generation set, and every exact Task 9 + `notification_fresh_installation_provenance` field. The issuer control plane must first + commit/read back the irreversible enforcement revision and all deployment-generation, + credential-issuance/revocation, DB-ingress and provider-egress deny facts; then terminate + cached legacy DB sessions and provider connections/flows; then observe the causally later + post-enforcement workload/business-consumer/node/session/flow zero manifest and provider + ledger settled cut with entry/open/indeterminate counts 0; then seal-commit/read back the + permanent irreversible fence; and only then sign. The canonical payload retains the exact + enforcement/fence canonical payloads, revisions, digests and activation/commit/read-back + times, post-enforcement manifest payload/digest/revision/time, ledger cut revision/time, + credential and denial-policy digests/booleans, + `legacy_database_session_inventory_digest`, + `legacy_database_session_open_count=0`, + `legacy_database_session_termination_evidence_digest`, + `legacy_provider_connection_flow_inventory_digest`, + `legacy_provider_connection_flow_open_count=0`, + `legacy_provider_connection_flow_termination_evidence_digest`, and both + established-flow-block facts. Every source evidence binds the same fence token and + enforcement revision. The independent issuer refuses pre-enforcement zero, cross-revision + composition, sign-before-seal, unsigned, uncommitted or reversible evidence. The retained + provenance stores those canonical + payload bytes, Ed25519 signature, bounded issuer public-key SPKI, key ID/digest, closed trust + snapshot and issued/expires/server-verified times. The signed profile pins + `allowedClockSkew` and `acceptanceMargin`; Java admits it only when + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`, verifies the + stored payload/signature/SPKI, and requires the historical-key digest to remain allowed and + non-revoked in the current closed catalog. Unknown/duplicate fields, noncanonical encoding, + algorithm/key downgrade, wrong identity, nonzero authority, missing/reversible fence, stale + birth certificate or token mismatch fail. + + `ProvisionFreshNotificationWriterFencesUseCase` rejects an ambient transaction and owns the + orchestration inside `TransactionPort.inRootWrite`. The dedicated provisioning composition + creates a provisioner-only `DataSource`, `PlatformTransactionManager` and + persistence-owned `PostgreSqlNotificationProvisionerTransactionAdapter` implementing + `TransactionPort`; none is a normal runtime/Flyway bean and the adapter proves its connection + has `current_user=notification_provisioner`. App-bootstrap owns only the dedicated data-source/ + transaction-manager composition and does not implement the transaction adapter. + `NotificationFreshProvisioningPort` exposes exactly two methods: + `snapshotAndReadLock(...)` and `applyVerifiedProvisioning(...)`. In one physical provisioner + connection/transaction the use case (a) calls `snapshotAndReadLock` for the exact + awaiting/empty inventory, server DB clock and DB identity, (b) passes that authoritative + snapshot and signed bytes to + `NotificationFreshProvisioningAuthorizationVerifierPort`, then (c) sends only the verified + facts to `applyVerifiedProvisioning`. The two calls may not use a second connection, nested + transaction, autocommit or a runtime/migrator transaction manager. + `PostgreSqlNotificationFreshProvisioningAdapter`, under `.postgresql.provisioning`, implements + only those structural lock/snapshot/CAS/insert operations; it never calls the verifier or owns + application policy. V8 exposes exactly two migrator-owned `SECURITY DEFINER` functions, + `notification_fresh_provisioning_snapshot_and_lock(...)` and + `notification_fresh_provisioning_apply(...)`, one for each port method. The apply function + proves the first function ran in this same physical transaction by checking its + transaction-local lock/snapshot proof, rechecks that those discriminator/inventory locks are + still held, and compares the first-stage DB-computed snapshot digest. It then rechecks + `AWAITING_SIGNED_FRESH_PROVISIONING`, store emptiness, current DB identity and the canonical + payload semantic digest from authoritative inputs, obtains a fresh `clock_timestamp()` and + rechecks the signed issued/expires/skew/acceptance window. A direct apply without the exact + first-stage lock ownership and snapshot digest fails before DML. It persists that apply-time DB value as + `server_verified_at`; a Java pause that crosses expiry yields mutation 0 even if the first + snapshot was valid. The root transaction inserts immutable signed provenance, exactly one + `INITIALIZE_CANONICAL_FRESH` header with the complete ordered route children, every + `ACTIVE/CANONICAL@initial` fence and CASes the discriminator to `FRESH_PROVISIONED` with its + fresh token and null validated-history digest. The operation returns its result only after + physical commit. A committed same-token/same-payload replay is a read-only result-recovery + branch: both functions lock/recompute the retained discriminator/provenance/init/fence + equality, Java reverifies the stored signature/trust/semantic facts and original + `server_verified_at` acceptance, and apply returns the persisted result without DML. It does + not apply current wall-clock expiry to that already accepted irreversible fact. A missing + result, different token/input/identity/digest, partial state or attempted new mutation must + take the fresh-time new-mutation branch or fail closed. Thus commit-success/result-loss is + recovered without a second initialization. Startup + remains dark until this transaction is durably committed, then a fresh startup re-verifies the + retained signed provenance in Java and may enter `REQUIRE_CANONICAL`. SQL enforces only + structural shape/count/digest/FK/immutability; it is never the Ed25519 authority. Authorization + expiry after accepted provisioning does not reverse the committed provenance or fences. + + Before V8, external DB-admin/IaC has already created the exact three-role set from Task 9. + V8 validates that set, ownership and grants and never executes `CREATE ROLE` or creates the + principal running itself. `notification_migrator` owns the schema, Flyway + history, migration objects and the two narrowly scoped provisioning functions. + `notification_runtime` is a nonowner. `notification_provisioner` has no table DML, sequence, + ownership, role-membership or DDL privilege and receives only `EXECUTE` on those exact two + functions. Both `SECURITY DEFINER` functions are migrator-owned, use + `SET search_path = pg_catalog` plus fully qualified objects, contain no dynamic SQL or + caller-selected object/action, check the exact caller role and lock their state/token/payload + rows; `PUBLIC` and runtime execute are revoked. PRE runtime retains only its separately + enumerated transitional `EXECUTE` grants until FINAL, when V8/cleanup revokes them. Tests reject + owner substitution, inherited membership, search-path shadowing and direct DML/sequence access. + Apply becomes state-closed after success; retained same-token replay is the only allowed result + path. + + On upgrade, replay the full causal journal by the operation and attestation values allocated + from the same DB sequence after the global/route fence lock: + `INITIALIZE_LEGACY -> (BEGIN -> TERMINALIZE* -> ABORT)* -> + BEGIN -> TERMINALIZE* -> COMPLETE`. Each non-init child has the exact drain-BEGIN FK; + expected/result owner/state/generation matches the closed matrix, terminalizer is unchanged + DRAINING, and COMPLETE is the last mutation. CANONICAL→BEGIN/TERMINALIZE/ABORT/ + second-COMPLETE, cross-table sequence collision/reversal, missing predecessor or + replay/fence/latest-pointer mismatch fails. `recorded_at`, `terminalized_at` and `observed_at` + must be post-lock `clock_timestamp()` values but are only sanity evidence; sequence/FK is the + causal SSOT. + + Reject orphan header/child, empty child set, action mismatch and any server-canonical + `route_set_digest`/`request_input_digest` recomputation mismatch, including terminalizer batch + bound. Both INITIALIZE actions have the exact reviewed all-route child set; every non-init + header has exactly one child. The retained immutable transport-proof registry, not the deleted + PRE catalog or caller digest, is upgrade proof authority: exact route/current+retiring profile + set, one ACTIVE profile, shared route digest and initialization-child FK; every frozen permit, + attestation and COMPLETE profile/proof/evidence/digest matches it. ACTIVE permits fail. + All-HARD_BOUND history requires the retained signed BEGIN header, an exact all-hard-bound + registry/evidence revision and permits only `RELEASED|EXPIRED_PROVEN`; ACTIVE permit 0 is + mandatory and quiescence attestation/children are forbidden. + + Every terminal permit composite-references its exact unchanged-DRAINING terminalizer child, + satisfies `expires_at <= terminalized_at` and + `BEGIN.operation_sequence < terminalizer.operation_sequence < + first-closing.operation_sequence`, and participates in the recomputed affected count/set + digest. QUIESCENCE_REQUIRED history additionally requires: BEGIN's signed complete-node + count/set/manifest digest exactly equals every immutable node row; all distinct permit holders + are included; attestation references that BEGIN and exact registry/permit/holder/node sets plus + independently signed per-node irreversible retirement/credential/egress fencing, consumer + inventory/count 0 and provider-call-ledger identity/open-count 0. The retained inventory and + quiescence/attestation headers and trust snapshots structurally bind the canonical signed + payload/signature, issuer public-key SPKI/key digest, issued/expires/verified fields and exact + environment/DB/artifact/inventory/ledger identities. Causal validity is + `BEGIN.operation_sequence < attestation.attestation_sequence < + COMPLETE.operation_sequence`, not a pre-commit timestamp. Current cleanup time is irrelevant. + A superseded/unselected attestation is allowed only with the same BEGIN and sequence before + the first closing ABORT/COMPLETE. Missing/extra/forged inventory, holder, attestation, + irreversible fact, provider-ledger snapshot or sequence fails. V8 performs structural + validation only; before activation Java re-verifies every stored payload/signature/SPKI, + applies the historical-key allow/non-revoked decision from the current closed catalog and + rejects a malformed acceptance window. Evidence accepted inside its pinned window remains + durable after expiry. Preserve fence, registry, permit, node inventory, per-node quiescence + evidence, signed headers/trust snapshots, attestation, provenance and operation rows + byte-for-byte. +- [ ] Implement the retained FINAL read path as a separate bounded application contract. + `NotificationRetainedWriterEvidenceQuery` supplies only expected environment/DB/artifact, + canonical route/generation set and reviewed per-collection bounds; + `NotificationRetainedWriterEvidenceQueryPort` returns one immutable + `NotificationRetainedWriterEvidenceSnapshot`. + `PostgreSqlNotificationRetainedWriterEvidenceQueryAdapter` performs read-only, deterministic + ordered reads and fails on truncation, extra rows, duplicate identities or any bound breach. + For `FRESH_PROVISIONED` it reads the discriminator, fresh provenance, exact + `INITIALIZE_CANONICAL_FRESH` header/children and exact canonical fence set. For + `UPGRADE_VALIDATED` it reads the discriminator plus the complete operation/route-child, + transport-proof registry, permit, BEGIN inventory/header/child, quiescence/attestation + header/child, trust-snapshot and exact canonical-fence snapshot needed to recompute the stored + validated-history digest. Awaiting or an arm overlap is never activation evidence. + `VerifyRetainedNotificationWriterEvidenceUseCase` is the only application-facing verifier. It + passes each bounded canonical payload/signature/SPKI/trust bundle to + `NotificationRetainedWriterEvidenceVerifierPort`; + `Ed25519NotificationRetainedWriterEvidenceVerifier` owns canonical decoding, Ed25519 + verification, issuer SPKI/digest, pinned trust snapshot and current closed-catalog + allow/non-revoked checks, and returns only bounded typed verified facts. The use case owns + branch policy: it compares those facts for exact semantic equality with the relational + projection, then checks discriminator XOR/token/history digest and exact catalog/generation + equality. Neither adapter makes branch/activation decisions, and the inventory-only + `NotificationWriterInventoryEvidenceVerifierPort` is not reused as if it covered fresh + provenance or the full FINAL snapshot. + App-bootstrap startup and readiness inject only this use case; composition/startup tests forbid + direct injection/import of a retained repository, JPA entity, `EntityManager` or JDBC type and + prove both exact arms activate only after successful Java verification. +- [ ] Register the following provisioning-source-set-only inputs; none is a + `NotificationSettings` field or normal runtime/Flyway input: + + | env key | purpose | classification | + | --- | --- | --- | + | `APP_NOTIFICATION_FRESH_PROVISIONING_AUTHORIZATION_REF` | externally issued signed DB-birth + committed irreversible-fence authorization bytes | sensitive reference | + | `APP_NOTIFICATION_FRESH_PROVISIONING_ISSUER_PUBLIC_KEY_REFS` | bounded verifier public-key SPKI refs matching the closed trust catalog | public verification-material refs | + | `APP_NOTIFICATION_DB_EXPECTED_PROVISIONER_ROLE` | exact callable principal | fixed `notification_provisioner` | + | `APP_NOTIFICATION_DB_PROVISIONER_USERNAME_REF` | dedicated provisioner username reference | sensitive reference | + | `APP_NOTIFICATION_DB_PROVISIONER_PASSWORD_REF` | dedicated provisioner password reference | sensitive reference | + + The CLI never logs or retains credentials in settings/application records/entities and wipes + only adapter-facing mutable copies on close. Do not claim end-to-end erasure: current + `SecretSource`/`EnvironmentSecretSource` and JDBC username/password APIs necessarily create + unavoidable short-lived immutable Java `String` values. Minimize copies and lifetime, run the + CLI as a dedicated forked process, assert `current_user`, immediately close the provisioner + `DataSource` and process after commit/failure, prohibit heap dumps for that process, rotate + short-TTL credentials, and prefer workload identity or certificate authentication where the + JDBC/runtime platform supports it. Tests cover no logging/exception/settings/entity retention, + minimum bridge copies, immediate close and mutable-copy wipe; residual JVM `String` exposure is + explicitly recorded rather than represented as wiped. The CLI carries no signing capability: + a production private key is forbidden in source, artifact, environment registry, test resource + and material source. Supplying these refs to an upgrade/runtime process, omitting one for + explicit fresh provisioning, sharing provisioner/runtime/migrator credentials or mismatching + the pinned SPKI digest fails closed. Retained provenance keeps the canonical payload, signature + and public verification/trust snapshot needed for future Java re-verification, never a private + key or database password. +- [ ] After registering `notificationFreshProvisioning` and + `notificationFreshProvisioningTest` with their exact application/outbound/persistence + classpaths, make the production provisioning configurations inherit only app-bootstrap's + already-governed main `implementation`/`runtimeOnly` configurations and its test + configurations inherit only the governed test configurations. Add an exact project-edge + assertion that scans every provisioning production/test configuration and rejects any direct + or inherited project dependency outside app-bootstrap's registry allowlist. + Register a non-mutating `notificationFreshProvisioningCheck` aggregate that compiles the + production provisioning source set with the repository's Java/Error Prone/static-analysis + policy and runs the project-edge and dependency/lock assertions, but does not run + `notificationFreshProvisioningTest`, invoke the provisioning CLI or reach a database. The + separately invoked `notificationFreshProvisioningTest` owns the Testcontainers/CLI integration + cases. Keep both outside ordinary `check` while requiring both explicitly in Task 22. + Regenerate and verify the app-bootstrap lock before compiling or invoking either task: + + ```bash + cd src && ./gradlew :app-bootstrap:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :app-bootstrap:verifyDependencyLocks --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningCheck --console=plain + ``` + +- [ ] RED/GREEN real-PostgreSQL migration cases: + a clean V1..V8 two-route database ends only in + `AWAITING_SIGNED_FRESH_PROVISIONING`, with fence/provenance/initialization rows 0 and application + provider resources 0. Invoking `notificationFreshProvisioning` with valid authorization then + creates the complete reviewed canonical initial set, exact retained provenance and one + `INITIALIZE_CANONICAL_FRESH` batch in one transaction and transitions the discriminator to + `FRESH_PROVISIONED` with fresh token present/validated-history digest absent. A valid canonical + upgrade ends only in `UPGRADE_VALIDATED` with complete-history digest present/fresh token and + provenance absent; every XOR/state violation is rejected. V8 rejects every nonempty/partial + notification inventory without an exact canonical fence set; the operation rejects + manually inserted/spoofed/expired/wrong-DB/wrong-environment/wrong-artifact/wrong-route-set/ + nonzero-node, nonzero-consumer or nonzero provider-ledger entry/open/indeterminate-count + authorization, missing/wrong DB birth certificate, every missing/mutated Task 9 enforcement, + post-enforcement zero-manifest, fence-read-back, credential, denial-policy, session or + connection-flow axis, and awaiting-state mutation. Prove the external issuer refuses + pre-enforcement zero, cross-revision evidence and signing before the exact enforcement → + termination → post-enforcement settled-zero → permanent fence seal/read-back chain completes. + Pause/inject an old deployment generation, + cached legacy DB/provider credential, established DB session/provider connection and legacy + egress path before provisioning; require both signed inventory digests/open counts 0, + termination evidences and ingress/egress established-flow block facts, mutate each exact field + independently as a RED case, then prove legacy DB I/O and provider I/O are both 0 before and + after provisioning and after resume. Prove boundary failures around both allowed + clock skew and acceptance margin. Assert both port calls/functions use the same + physical connection/root transaction; pause Java verification beyond expiry and require apply + mutation 0, invoke the apply function directly after expiry and require mutation 0, and cover + rollback after each write stage plus commit-success/result-loss recovery. A failed provisioning + transaction leaves awaiting state unchanged; same-token/same-payload retry is exact and + mismatch retry fails rather than reclassifying a partial database or blessing an upgrade. + A committed same-token replay after current authorization expiry reverifies the stored + signature/trust/semantic facts and original `server_verified_at`, returns the stored result + with DML 0 and never refreshes acceptance time. Direct apply without the first-stage + transaction-local lock/snapshot proof fails with mutation 0. Also invoke both functions + directly outside the CLI/use-case seam with forged signature or + forged typed facts: regardless of any structurally written row, FINAL startup/readiness Java + verification must detect payload/SPKI/trust/semantic inequality and remain dark/ + `NOT_QUALIFIED`. A valid signed authorization may reach direct apply only when every + apply-time DB-clock/fence/state/identity condition still holds, and provider I/O remains 0 + until a subsequent startup successfully re-verifies the retained evidence. + A multi-route upgrade with different valid `g_final` values preserves every fence and + proof-registry/permit/node-inventory/quiescence-node-evidence/signed-header/trust-snapshot/ + attestation/ + operation-history row + byte-for-byte. Include a valid + multi-profile + QUIESCENCE_REQUIRED upgrade whose attestation is expired now but was admitted inside its signed + acceptance window and whose irreversible facts remain valid. + Include successful re-attestation after a permit row-version change, and + BEGIN→attestation→ABORT→new BEGIN→new attestation→COMPLETE history; the superseded rows remain + byte-identical. BEGIN→ABORT→forged late attestation and + BEGIN→COMPLETE→forged late unselected attestation fail without mutation. + A pre-BEGIN-expired old-generation ACTIVE permit terminalized after BEGIN then completed passes + V8; expiry is not required to follow BEGIN. + Add rogue histories that end canonical but contain + COMPLETE→BEGIN→ABORT→BEGIN→COMPLETE, terminalizer outside DRAINING, sequence + duplicate/cross-table-collision/reversal or a missing drain-BEGIN predecessor; each fails + without mutation. Start a terminalizer transaction before BEGIN and release its fence wait + after BEGIN; post-lock `clock_timestamp()` and later sequence must make this valid, proving + transaction-start time is not used. Add orphan + header/child, empty/extra/partial init child set, zero/two-child non-init header, route-set + digest, request-input digest and stored batch-bound corruption fixtures. + Missing/extra/partial route sets, LEGACY/DRAINING/mixed owners, latest-COMPLETE mismatch, + ACTIVE old-generation permit, stale-at-COMPLETE/missing/wrong token or set/profile digest, + unknown/omitted/extra registry profile, tampered proof class/evidence revision/registry digest, + either invalid proof-class/state pairing, orphan/wrong-action/wrong-route/wrong-set + terminalization, terminalized-before-expiry/after-close, omitted/extra inventory node or permit + holder, unsigned/wrong-issuer/wrong-identity quiescence manifest, missing/extra/wrong-action/ + wrong-attestation/wrong-profile, malformed stored payload/signature/SPKI/trust snapshot, + revoked historical key, invalid issued/expires/verified window, nonzero facts, BEGIN-less + attestation-only nonempty + store and every other + nonempty-without-fence fixture all fail without mutation. An app-bootstrap composition test + also proves exact equality between the compiled canonical route catalog, + `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS` and the persisted fence set. The retained-reader + tests cover bounded/truncated/extra child snapshots, both valid arms, every semantic + payload-to-row mismatch, malformed/revoked SPKI/trust state, discriminator XOR and digest + mismatch, and prove bootstrap has no direct repository/entity access: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*ProvisionFreshNotificationWriterFencesUseCaseTest' \ + --tests '*VerifyRetainedNotificationWriterEvidenceUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*Ed25519NotificationFreshProvisioningAuthorizationVerifierTest' \ + --tests '*Ed25519NotificationRetainedWriterEvidenceVerifierTest' \ + --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*PostgreSqlNotificationFreshProvisioningIntegrationTest' \ + --tests '*PostgreSqlNotificationProvisionerTransactionAdapterTest' \ + --tests '*PostgreSqlNotificationRetainedWriterEvidenceQueryAdapterTest' \ + --tests '*NotificationRetainedEvidenceNoSaveArchitectureTest' \ + --tests '*NotificationWriterFinalizationDiscriminatorIntegrationTest' \ + --tests '*NotificationCanonicalFenceInitializationMigrationTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationCanonicalWriterFenceSetCompositionTest' \ + --tests '*NotificationRetainedWriterEvidenceCompositionTest' \ + --tests '*NotificationRetainedWriterEvidenceStartupTest' \ + --tests '*NotificationDatabaseRoleCompositionTest' \ + --tests '*NotificationFlywayRoleIsolationTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningTest \ + --tests '*NotificationFreshProvisioningCliIntegrationTest' \ + --tests '*NotificationFreshProvisioningSettingsTest' \ + --tests '*NotificationProvisionerTransactionCompositionTest' --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningCheck --console=plain + ``` + +- [ ] Only after the human observation gate passes, delete the listed legacy and bridge files. + Remove legacy selector rows from `src/.env`, both application YAML files and `env-keys.yaml`; + remove `APP_NOTIFICATION_SLACK_WEBHOOK_URL` from the secret registry only after the legacy + webhook code and all references are gone. Update disabled/sentinel/conditional/fail-open tests + and root documentation in the same RED/GREEN step. +- [ ] Delete the transitional operator controller/DTO/test, proxied switch operation/use case, + legacy permit, expired-permit terminalizer and quiescence-attestation + write port/operation/use-case/write adapter, + bridge and the `notification:cutover`/`notification:cutover-terminalize`/ + `notification:cutover-attest` operator mappings in the + same cleanup release. Delete + `NotificationWriterCutoverPort`, `PostgreSqlNotificationWriterCutoverAdapter`, + `PostgreSqlNotificationWriterQuiescenceAttestationAdapter` and their integration tests; remove + ACTIVE/TIMED_OUT_UNPROVEN permit, attestation and max-expiry fields/read paths from + `NotificationOperationsSnapshot`, its application test and persistence adapter/test. Keep the + additive transport-proof-registry/permit/operation/BEGIN-node-inventory/ + quiescence-node-evidence/signed inventory/quiescence/attestation headers, trust snapshots, + fresh-provenance/finalization-discriminator table history, all corresponding + entities and repositories as retained projections, and + `PostgreSqlNotificationRetainedWriterEvidenceQueryAdapter`; these are not deletion targets. + Keep the Task 9 never-Java-written repositories on their original marker-only contract. + After deleting write adapters, narrow every remaining retained transitional Spring Data + repository to the marker `Repository` plus only explicitly bounded read methods; none may extend + `CrudRepository`/`JpaRepository` or declare `save`, `saveAll`, `delete` or `flush`. + `NotificationRetainedEvidenceNoSaveArchitectureTest` scans this contract and the read adapter + for a write surface. + Keep the separate + `NotificationCanonicalWriterFencePort`/adapter/guard, retained inventory and FINAL Ed25519 + evidence verifiers and + explicit fresh-provisioning operation, but + expose no runtime operation capable of selecting `LEGACY` or mutating retained audit rows; + configured startup requires the persisted owner to be the exact canonical generation. Remove + every bridge/permit/terminalizer/proxied-switch/controller-support bean from + `NotificationCompositionConfig` and update composition, zero-resource and trust-context tests + so no deleted transitional type remains reachable. FINAL grants revoke generic + `INSERT|UPDATE|DELETE` on retained cutover audit/control tables, cutover-sequence use and every + PRE transitional function `EXECUTE` from runtime/PUBLIC; runtime retains only exact SELECT and + fence read-lock access there. This does not revoke the exact DML/SELECT needed for the active + intent/claim/finalize/receipt operational journal. The provisioner retains only the exact + two state-closed provisioning-function `EXECUTE` grants, never direct audit-table DML or + sequence access. App-bootstrap may reach retained evidence only through + `VerifyRetainedNotificationWriterEvidenceUseCase`; repository/entity injection or direct JDBC + is a composition-test failure. +- [ ] Rewrite `NotificationQualificationOwnershipSetup` in the cleanup source tree as a FINAL-only + migration/validated-fence-set setup. It must not import, reflectively load or string-reference + any deleted initializer/switch/permit/terminalizer/attestation/controller type. Run the + compile/test after + deletion so Task 22 can requalify the final artifact without a PRE-only setup path. Its fresh + lane must run V8 to `AWAITING_SIGNED_FRESH_PROVISIONING` and invoke only the signed + `notificationFreshProvisioning` Gradle/CLI path, ending only in `FRESH_PROVISIONED` with the + retained DB birth certificate/no-legacy-authority fence and fresh-token XOR arm; its upgrade + lane may only validate an existing complete canonical history and end in + `UPGRADE_VALIDATED` with the retained-history-digest XOR arm. +- [ ] Delete every `CUTOVER_WAIT` and PRE production branch from `NotificationWriterStartupMode`, + settings, composition, activation gate, YAML/env registry and tests. Retain only the closed + FINAL modes `AWAITING_SIGNED_FRESH_PROVISIONING|REQUIRE_CANONICAL`. + `NotificationWriterActivationGate` keeps admission/claim/call 0 while awaiting; in + `REQUIRE_CANONICAL`, absent, predecessor, DRAINING, partial, extra or wrong-generation fences + fail startup and admit/claim/call 0. Both fresh and upgrade startup re-read the retained signed + headers/provenance through `VerifyRetainedNotificationWriterEvidenceUseCase`, require the + matching actual finalization-discriminator arm, and reverify payload/signature/SPKI, semantic + equality plus current historical-key status before activation. Awaiting, unknown, + discriminator/provenance/history overlap or direct-SQL forged state stays dark. + Detector fixtures may retain the explicit marker only under their exact verification + allowlist; remove `APP_NOTIFICATION_CUTOVER_ATTESTATION_TTL` and rename the PRE public verifier + input to `APP_NOTIFICATION_RETAINED_EVIDENCE_ISSUER_PUBLIC_KEY_REFS`. Retain that read-only + runtime trust input and the provisioning-source-set-only authorization/public-key/provisioner + references in their narrow registry paths. The compiled runtime configuration contains no + CUTOVER_WAIT, terminalizer or attestation-write support, but does retain read-only signed + evidence verification. +- [ ] Delete PRE-only `NotificationCutoverRouteCatalog` and transitional + `NotificationWriterRouteSet` with all legacy alias/transport-proof consumers. Final + composition retains only + `NotificationCanonicalRouteCatalog -> NotificationCanonicalWriterRouteSet` plus the exact + runtime target-generation map; its key set must match V8 and persisted fences. Detector tests + require executable legacy alias/proof classes/beans/config 0 while allowing immutable V7 + proof-registry history and V8 SQL validation references. +- [ ] Remove `spring-web` direct dependency if no new notification code uses it, regenerate only + affected locks, and re-run dependency verification. +- [ ] Never backfill old generic outbox/log events or automatically resend accepted/indeterminate + legacy occurrences. +- [ ] Verify the 21C canonical-only artifact: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*Notification*' \ + --tests '*DisabledAdapterSentinelTest' \ + --tests '*OptionalAdapter*' \ + --console=plain + cd src && ./gradlew :application-core:check \ + :adapter:outbound:notification:check \ + --console=plain + cd src && ./gradlew verifyEnvKeys \ + verifyDependencyLocks \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Acceptance: exactly one canonical activation graph remains; legacy names have no production + source/config consumer and occur only in the reviewed release-stage detector/test allowlist; + optional webhook/Gmail/SMTP can return only as separately designed exact cards. The cleanup + artifact remains `NOT_QUALIFIED` until Task 22 final-artifact requalification. + +**Rollback checkpoint:** after legacy deletion, do not restore it for rows already accepted or +indeterminate. Pause admission, retain schema/revisions and forward-fix unless an exact route inventory +proves zero duplicate risk. + +### Task 22: Synchronize truth, run full gates, independent review and LLM Wiki capture + +**Owner:** repository-wide verification/documentation +**Depends on:** all preceding tasks required by the selected release scope + +**Files — modify:** + +- `docs/superpowers/specs/2026-07-28-notification-production-capability-design.md` +- `docs/superpowers/plans/2026-07-28-notification-production-capability.md` +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` +- `src/adapter/outbound/notification/README.md` +- `src/adapter/outbound/notification/CLAUDE.md` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` +- `docs/runbooks/notification.md` +- `docs/runbooks/notification-database-role-bootstrap.md` +- LLM Wiki branch-note and only genuinely derived raw documents + +- [ ] Update implementation status from actual source/test/evidence only. Keep every unexecuted + provider/load/rotation lane visibly `NOT_QUALIFIED`. +- [ ] Run focused owner gates first: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:notification:check \ + :adapter:outbound:persistence-jpa:check \ + :adapter:inbound:web:check \ + :app-bootstrap:check \ + --console=plain + ``` + +- [ ] Run the exact database-role/provisioning gates and require + exactly three roles: `notification_migrator` owner/Flyway-only, + `notification_runtime` nonowner with exact FINAL operational grants and every PRE transitional + `EXECUTE` revoked, and `notification_provisioner` execute-only on exactly + `notification_fresh_provisioning_snapshot_and_lock` and + `notification_fresh_provisioning_apply`. Any additional notification-scoped + owner/member/grantee, direct provisioner DML/sequence privilege or PUBLIC/runtime execution + of either function fails. Require the external DB-admin/IaC bootstrap revision/digest and + prove V7/V8 contain no role creation: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationDatabaseRoleIsolationIntegrationTest' \ + --tests '*PostgreSqlNotificationFreshProvisioningIntegrationTest' \ + --tests '*PostgreSqlNotificationProvisionerTransactionAdapterTest' \ + --tests '*PostgreSqlNotificationRetainedWriterEvidenceQueryAdapterTest' \ + --tests '*NotificationRetainedEvidenceNoSaveArchitectureTest' \ + --tests '*NotificationWriterFinalizationDiscriminatorIntegrationTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationDatabaseRoleCompositionTest' \ + --tests '*NotificationFlywayRoleIsolationTest' \ + --tests '*NotificationRetainedWriterEvidenceCompositionTest' \ + --tests '*NotificationRetainedWriterEvidenceStartupTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningTest \ + --tests '*NotificationFreshProvisioningCliIntegrationTest' \ + --tests '*NotificationFreshProvisioningSettingsTest' \ + --tests '*NotificationProvisionerTransactionCompositionTest' --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningCheck --console=plain + ``` + +- [ ] Run repository gates: + + ```bash + cd src && ./gradlew test --console=plain + cd src && ./gradlew check --console=plain + cd src && ./gradlew verifyDependencyLocks --console=plain + cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain + cd src && ./gradlew verifyPublicPathSnapshot --console=plain + cd src && ./gradlew verifyEnvKeys --console=plain + ``` + +- [ ] Run documentation/source hygiene: + + ```bash + git diff --check + if rg --hidden -n 'slack-webhook|google-email|\bNotificationPort\b|\bRoutingNotifier\b|APP_NOTIFICATION_SLACK_WEBHOOK_URL' \ + src docs/registries \ + -g '**/src/main/**/*.java' \ + -g '**/src/main/**/*.yml' \ + -g '**/src/main/**/*.yaml' \ + -g '**/src/main/**/*.properties' \ + -g '**/build.gradle' -g '.env' -g '*.yml' -g '*.yaml' \ + -g '!**/build/**' -g '!**/.git/**'; then + echo 'legacy notification production consumer/config references remain' >&2 + exit 1 + fi + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationReleaseStageLegacyMarkerAllowlistTest' \ + --tests '*NotificationReleaseStageDetectorTest' \ + --console=plain + ``` + +- [ ] Perform independent reviews for: + Clean Architecture/module boundary; transaction/durability/concurrency; callback/security; + provider protocol; privacy/operations. Completion requires blocker 0 and high 0. Any unresolved + blocker/high finding keeps this task incomplete and the card `NOT_QUALIFIED`. +- [ ] Freeze the final cleanup source tree, docs and dependency locks. Because commits are + human-only, a human creates the candidate commit; CI builds the final artifact from that exact + commit and records its artifact digest. The agent never stages, commits or pushes. +- [ ] Treat every Task 19/20 `PRE_CUTOVER_BRIDGE` manifest as stale for this final artifact. Deploy + the exact cleanup artifact to the isolated sandbox. Before any lane, require + `NotificationReleaseStageDetector` to derive `FINAL_CLEANUP` from the built artifact; a + caller-supplied stage, PRE, mixed or unknown inventory fails. Re-run both FINAL ownership setup + variants first. `FRESH` runs V1..V8 to `AWAITING_SIGNED_FRESH_PROVISIONING`, proves every + runtime side effect 0, commits the external permanent no-legacy-authority fence, and only then + obtains a production issuer-signed database-birth authorization and invokes the explicit task + below. It must finish with actual discriminator `FRESH_PROVISIONED`, fresh token present and + validated-history digest absent; wrong/absent authorization or an + unsigned/uncommitted/reversible fence remains dark. Its retained evidence includes the DB + birth certificate and the complete exact Task 9 fresh-provenance field set and causal order: + enforcement commit/read-back, cached DB-session/provider-flow termination, causally later + post-enforcement zero manifest with provider-ledger entry/open/indeterminate counts 0, then + irreversible fence seal/read-back before signature. A paused old generation with cached + credential/session/connection proves legacy DB I/O and provider I/O are both 0 before and + after provisioning. `UPGRADE` validates complete canonical history byte-for-byte without fresh + provenance, never invokes the task and must finish with actual discriminator + `UPGRADE_VALIDATED`, complete-history digest present and fresh token absent. Both paths call + `VerifyRetainedNotificationWriterEvidenceUseCase` to reverify retained payload/signature/SPKI, + semantic equality and closed-catalog historical-key status in Java before activation; + bootstrap reads no repository/entity directly: + + ```bash + # FRESH ownership setup only; never run on UPGRADE: + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioning --console=plain + cd src && ./gradlew :app-bootstrap:notificationQualification --console=plain + cd src && ./gradlew :app-bootstrap:notificationSlackReadiness --console=plain + cd src && ./gradlew :app-bootstrap:notificationSesReadiness --console=plain + # Required only when the release-selected SES card requires a fresh scheduled/manual DLQ drill: + cd src && ./gradlew :app-bootstrap:notificationSesDlqReadiness --console=plain + cd src && ./gradlew :app-bootstrap:notificationProductionReadiness --console=plain + ``` + + The final aggregator compares the human candidate source commit, production-source/dependency + digest, deployed artifact digest, detector-derived `FINAL_CLEANUP` stage and every exact + card/topology axis. It must reject all bridge manifests, any hardcoded/caller-overridden stage + and any mismatched/expired final lane. Every selected FINAL lane must carry only the cleanup + migration/V8 structural digest and exactly one closed arm: + `FINAL.FRESH` carries discriminator `FRESH_PROVISIONED` with fresh-token XOR, the signed DB + birth certificate, the complete exact Task 9 fresh-provenance field set and + enforcement-read-back → cached-session/flow termination → post-enforcement settled-zero + manifest → irreversible fence seal/read-back → signature causal order, signed fresh + provenance/trust snapshot and `INITIALIZE_CANONICAL_FRESH`; `FINAL.UPGRADE` carries + discriminator `UPGRADE_VALIDATED` with + complete retained-history-digest XOR and forbids fresh provenance. + `AWAITING_SIGNED_FRESH_PROVISIONING`, a PRE ownership arm or reference to a deleted + cutover type fails aggregation. If a scheduled destructive DLQ manifest is + still fresh under the exact artifact/card policy it may be selected; otherwise the + human-approved DLQ lane is rerun. No code/config/docs change is allowed after this build without + invalidating the final evidence and repeating this gate. +- [ ] Only successful final-artifact aggregation permits the selected exact card to be called an + operational R2 candidate. If sandbox credentials/topology or the human candidate commit are not + available, record the blocker and keep the card `NOT_QUALIFIED`; do not reuse pre-cutover + evidence. +- [ ] Before Wiki capture, read the configured vault's `AGENTS.md`, `CLAUDE.md`, and relevant + `rules/`, `.agents/`, `.claude/`, `.codex/` instructions. Then update exactly + `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/.md` with + implementation, files, decisions, commands/results/failures, evidence grade and remaining + risks. +- [ ] Add `raw/errors`, `raw/interviews`, `raw/blog-topics` only when honestly derived; link each child + upward and the branch-note cluster back to each child. Otherwise record “없음” explicitly. +- [ ] Do not mark this task complete if the canonical Wiki path is unavailable; record the precise + capture blocker. +- [ ] A repository-wide gate that cannot run because of environment/tooling/authorization is a + recorded blocker, not a pass. State the exact command and residual risk; do not claim Task 22 + complete. +- [ ] Final handoff must list changed files, core behavior, exact verification results, not-run/failed + commands, Wiki capture, evidence level and follow-up risks. + +**Rollback checkpoint:** documentation must describe the deployed/evidenced truth, not the preferred +rollback story. Do not rewrite evidence after a failed rollout. + +--- + +## 7. Task dependency graph + +```text +1 +└─ 2 + └─ 3 + └─ 4 + ├─ 5 ─ 6 ─ 7 ────────────────┐ + └─ 8 ─ 9 ─ 10 ─ 11 ─ 12 ────┤ + └─ 13 ─┬─ 14 + └─ 15 ─ 16 ─ 17 ─ 18 ─ 19 ─ 20 ─ 21 ─ 22 +``` + +Tasks within one owner leaf may be implemented sequentially by one agent. Parallel work is safe only +after the shared application contracts are GREEN: + +- Task 13 must finish before Task 15 because both change the notification leaf dependency declaration + and lock; Task 14 may run in parallel with Task 15 only after Task 13's card/client seam is stable. +- Task 16 may start after the normalized receipt contract and SES event profile are frozen. +- Persistence Tasks 9–11 must not run concurrently against the same migration/store files. +- Composition Task 17 starts only after provider, persistence and ingress descriptors are stable. +- Legacy deletion Task 21 is never parallelized with provider/composition work. +- Task 20 local harness implementation may begin after Task 18, but its final aggregator cannot run + until Task 19 has emitted every selected provider manifest. + +## 8. Minimum implementation completion matrix + +| requirement | proving task | +| --- | --- | +| canonical binding/expected state, legacy conflict | 5, 17, 21 | +| feature-specific semantic pattern | 3–4 | +| frozen intent/template/route | 3, 5–6, 10 | +| best-effort/durable separation | 2–4, 14 | +| same-DB journal | 9–12 | +| token/version claim/finalize | 11–12 | +| encrypted PII/HMAC/retention | 8–10, 18 | +| indeterminate/reconcile/fallback safety | 4, 11–12, 14–16 | +| Slack exact cards | 13–14, 19–20 | +| SES/SNS exact card | 15–16, 19–20 | +| zero-resource disabled | 17–18 | +| bounded deadlines/concurrency/amplification | 4–7, 11–15, 18–20 | +| health/metrics/traces/runbook | 18, 20 | +| independent review | 22 | +| LLM Wiki capture | 22 | + +`minimum implementation R2`는 표의 required task가 실제로 GREEN이고 selected exact card의 +no-skip evidence가 fresh할 때만 사용할 수 있다. 일부 task만 끝났다면 해당 evidence row의 +좁은 표현만 사용한다. + +## 9. Final non-negotiable assertions + +- provider accepted와 recipient delivered/read는 다르다. +- `WIRE_AUTHORIZED` 이후 unknown은 definite-not-sent가 아니다. +- Slack/SES send API에는 이 설계가 의존할 exactly-once idempotency가 없다. +- shared admission park는 durable state이며 process-local circuit breaker가 아니다. +- config는 application mode/admission/business policy를 선택하지 않는다. +- callback authenticity와 same-transaction receipt commit 전에는 SNS success ACK를 반환하지 + 않는다. +- disabled와 configured-but-broken은 다른 상태다. +- fake/loopback/PostgreSQL evidence만으로 real provider card를 R2라고 부르지 않는다. +- agent는 stage/commit/amend/push하지 않는다. diff --git a/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md b/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md new file mode 100644 index 0000000..8bb5383 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md @@ -0,0 +1,3451 @@ +# Object Storage Production Capability Implementation Plan + +- 작성일: 2026-07-28 +- 상태: 구현 계획 작성 완료, 모든 task 미착수, R2 미구현 +- 범위: 상세 설계 Phase 0–6; Phase 7/R3는 별도 승인 계획 +- LLM Wiki capture: 정본 vault + `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함 + +> **For agentic workers:** REQUIRED SUB-SKILLS: use +> `superpowers:subagent-driven-development` to execute independent tasks, +> `superpowers:test-driven-development` for every behavior change, and +> `superpowers:verification-before-completion` before changing any readiness claim. Track progress +> with the checkboxes in this document. Repository policy is `human-only`: do not stage, commit, +> amend, or push. + +**Goal:** Replace the caller-keyed, whole-object `byte[]` example with a framework-free, +bounded-streaming object publication capability, migrate the sample Poster workflow through a +durable database/object-storage handoff, and qualify only exact provider/card combinations for +which the required evidence exists. + +**Architecture:** `application-core` owns provider-neutral identities, content callbacks, requests, +receipts, outcomes, and narrow outbound ports under `dev.caskeleton.application.objectstorage`. +`adapter:outbound:objectstorage` owns immutable data/control namespaces, canonical codecs, +operation state machines, provider bindings, filesystem/S3 implementations, reconciliation, and +readiness evidence. The legacy `dev.caskeleton.application.storage` CRUD contract remains isolated +until the sample and stored data have migrated. `sample-portfolio` owns the business `UploadIntent`, +database attachment choreography, and public HTTP contract; the object-storage adapter never reads +the sample database. + +**Tech Stack:** Java 21, Spring Boot 4.0.0 configuration properties/autoconfiguration, Gradle +multi-module verification, JUnit 5, AssertJ, jqwik `1.9.1`, AWS SDK for Java v2 `2.30.0`, +`S3AsyncClient` with the Java Netty NIO HTTP client, Testcontainers `2.0.2`, version-pinned MinIO, +Toxiproxy, PostgreSQL/Flyway for the sample workflow, Micrometer/Actuator. + +**Authoritative design:** +[Object Storage Production Capability Deep Design](../specs/2026-07-28-objectstorage-production-capability-design.md). +When this plan and that design differ, stop and amend the design before implementing. Module edges +remain governed only by `src/config/architecture/modules.json`. + +**Scope boundary:** This is the executable master plan for design Phases 0–6. It deliberately does +not claim that all tasks belong in one pull request or release. Phase 7 multi-node/R3 work, a +provider-leaf split, a production malware-scanner implementation, and browser API product choices +outside the approved contracts require follow-up plans. Writing this plan changes no runtime +behavior and advances no readiness card. + +--- + +## Execution rules + +1. Execute batches in order. A later batch may start only after the preceding checkpoint passes. +2. Within a task, write the named failing test first, run the stated RED command, make the minimum + implementation, then run the same command GREEN. +3. A compilation failure counts as RED only when the missing symbol is the symbol the task is + intentionally introducing. Dependency, formatting, daemon, Docker, credential, or unrelated + compilation failures do not count. +4. Preserve the legacy port as an isolated compatibility seam. New business code must never import + `dev.caskeleton.application.storage`. +5. Do not expose AWS SDK, Spring, `Path`, bucket, raw key, provider endpoint, raw ETag, upload ID, + persistent provider locator, or inbound DTO types through `application-core`. A bounded + presigned URI exists only in the explicit transient direct-grant value, is always redacted, and + is never persisted in a receipt/control record. The only raw-locator exception is the exact, + deprecated, admin-only `dev.caskeleton.application.storage.migration` adoption seam in Task 24; + its value is bounded, redacted, never logged/serialized into a receipt, and may not be injected + into a normal business use case. +6. Do not call a producer while a database transaction is open. Do not claim that a database + rollback reverses an object mutation. +7. Unsupported provider behavior is a typed rejection or startup failure, never a fallback, + check-then-overwrite emulation, test skip, or weaker silent guarantee. +8. `filesystem-local-dev` and the pinned MinIO topology have an R1 ceiling. AWS S3 also remains + below R2 until the Phase 6 protected qualification lane passes for an exact provider, destination + profile, card, and evidence revision. +9. Run Spotless only on files changed by this plan. Do not format or rewrite unrelated dirty files. +10. At every batch boundary, update the implementation-status section in the design and this plan. + Do not mark a task complete from code inspection alone. +11. Every Batch A–F checkpoint is a meaningful-work capture boundary. Re-read the canonical LLM + Wiki instructions, update the branch note and any honest derivatives, or record the exact + canonical-vault access block in both this plan and the design before starting the next batch. + A final Task 30 capture does not replace these per-batch records. + +## Frozen implementation decisions + +These decisions translate §37 of the design into executable constraints. A row marked +**approval gate** is intentionally not delegated to an implementation task. + +| Concern | Decision for this plan | +| --- | --- | +| Application package | New contract lives under `dev.caskeleton.application.objectstorage` with `identity`, `content`, `model`, `request`, and `port` subpackages. | +| Legacy coexistence | `dev.caskeleton.application.storage.ObjectStoragePort` and `StoredObject` become deprecated legacy-only types. They remain readable until sample data migration, dual-read observation, API snapshot approval, and zero production usages are evidenced. | +| Control format | `canonical-json-v1`: UTF-8, fixed field order, no insignificant whitespace, decimal integers, canonical enum names, strict duplicate/unknown-field rejection, bounded record-family sizes, and an outer SHA-256 corruption digest. R2 relies additionally on private namespace/IAM and provider encryption; the digest is not described as tamper authentication. | +| Record sizing | Operation/reference/session records are at most 64 KiB; terminal receipts at most 16 KiB; multipart part receipts are separate immutable records of at most 4 KiB each. A 10,000-part ledger is never materialized into one control object. | +| Conditional CAS | S3 uses `PutObject` `If-None-Match: *` for reserve and exact private ETag `If-Match` for revision CAS. The pinned SDK API must be characterized before use. MinIO must prove the same semantics in a non-skipping integration test. Filesystem local-dev uses process lock plus exclusive create/atomic replace and therefore stays R1. | +| Public reference | `osr1...` where route is 12 lowercase Crockford Base32 characters, object is 26 lowercase Crockford Base32 characters (128 random bits), and check is the first 10 lowercase hex characters of SHA-256 over the first three components. Total parsing is bounded; the value contains no provider locator and is not authorization. | +| Stage/session handles | Separate prefixes (`osh1`, `osu1`, `osm1`) and the same bounded route/random/check structure. A stage or session handle is rejected by published-reference parsers and public read ports. | +| Operation deadline | Reuse `dev.caskeleton.application.outbound.CallBudget` as the absolute monotonic parent budget. Add a framework-free `CancellationView`; never serialize either value into durable control records. Persist only wall-clock attempt/lease timestamps and bounded policy durations. | +| Async S3 HTTP | Use `S3AsyncClient` with an explicitly configured `NettyNioAsyncHttpClient`. Do not use CRT, `S3TransferManager`, or SDK-owned automatic multipart for an R2 card. | +| AWS SDK | Keep the repository SSOT `awsSdkVersion = 2.30.0` for this plan. Characterize checksum defaults, conditional builders, and the `mpuObjectSize(Integer)` boundary. A version upgrade needs its own dependency/evidence decision. | +| Local-dev root | No code default. Unit tests use `@TempDir`; the explicit sample-local profile may bind `./.data/object-storage-v1`. Production profiles reject `filesystem-local-dev`. | +| MinIO identity | Initial test identity is `s3-compatible-minio-community-release-2024-01-16t16-07-38z`, corresponding to the existing `minio/minio:RELEASE.2024-01-16T16-07-38Z`. Phase 3 acceptance requires conversion to an image digest pin and records that digest as provider-version evidence. | +| Provider IDs | Only `filesystem-local-dev`, `aws-s3-general-purpose`, and the exact MinIO identity above are implemented in this plan. `filesystem-local-persistent` remains a follow-up provider. | +| Binding prefix | Canonical prefix is `app.object-storage`; `enabled` defaults to `false`, and there is no default provider or destination. Any simultaneous `ca-skeleton.objectstorage.*` and canonical configuration fails startup without logging values. | +| Composition SPI | Task 9 creates a side-effect-free provider-contribution registry. Settings are fully compiled before the assembler asks only selected contributions to construct clients. Normal semantic routers, scan-maintenance routers, and privileged purge routers are distinct concrete types/configurations; no catch-all router exposes a privileged port in a normal application context. Every later provider slice updates its explicit contribution and selected/unselected/disabled composition test—component scanning is not provider activation. | +| Bootstrap edge | Do not add an `app-bootstrap -> adapter-outbound-objectstorage` edge in this plan: there is no production use-case owner there. The sample already has the registered runtime-only edge. A future production owner requires a separate registry/Gradle approval. | +| Scanner owner | Application contracts and staged scan fencing are implemented; tests use a fake scanner verdict source. Selecting and implementing a production scanner is an **approval gate** and is required before the quarantine-publication card can claim R2. | +| Sample durable work | Use a dedicated `poster_image_upload_intent` table/repository and reconciliation use case. Do not reuse broker-delivery outbox rows as an object-storage operation journal. Object bytes never enter that table. | +| API compatibility | Preserve the existing `/posters/{id}/image` response during the legacy window. A new opaque-reference response and any upload-session endpoints require an **approval gate** plus intentional OpenAPI snapshot regeneration; no task silently removes `key`/`location`. | +| Direct-card ceiling | This plan implements provider/session primitives but no approved public direct-upload/download/session endpoint. Therefore direct single, direct multipart, and direct-download cards remain at most R1/partial and Task 29 may not promote them to R2. A follow-up inbound authorization/rate-limit/API snapshot plan is required. | +| Readiness registry | Add `docs/registries/object-storage-readiness.yaml`, schema version 1, with exact `card_id`, provider type/version, destination profile, claimed level, evidence revision/expiry, required non-skipping Gradle tasks, and limitations. Runtime descriptors are derived from compiled binding plus live qualification; the registry is the CI claim manifest, not a substitute for probes. | +| Cleanup ownership | Maintenance lease is `(destination route, job id, owner token, monotonically increasing fence, expires-at)`. Every destructive cleanup also needs an exact object/version precondition and an application handoff/abort authorization. Age or LIST absence alone never authorizes deletion. Report-only is the default. | +| Phase 7 | Phase 6 must still prove bounded backup/restore reconciliation for every exact R2 reconciliation card in a disposable namespace. Regional/cluster disaster-recovery game days, multi-node failover/fencing, sustained scale, and the provider-leaf split/no-split ADR are excluded and require a new approved plan. | + +## Batch graph and promotion gates + +```text +Batch A: Phase 0–1 contract + -> Batch B: Phase 2 provider-neutral kernel + local R1 + -> Batch C: Phase 3 managed S3/MinIO common subset + -> Batch D: Phase 4 direct transfer + multipart + -> Approval Gate A: scanner owner + sample API contract + -> Batch E: Phase 5 staged publication + sample migration + -> Approval Gate B: AWS sandbox/IaC/workload identity + -> Batch F: Phase 6 exact R2 security/maintenance/readiness + -> separate Phase 7 plan +``` + +| Checkpoint | Minimum acceptance | Rollback posture | +| --- | --- | --- | +| A | Application contracts are pure; legacy behavior is characterized; no provider types leak. | Keep new binding disabled and old consumer unchanged. | +| B | Local-dev passes bounded provider contract and restart characterization; only R1 is published. | Disable canonical capability; legacy local example remains isolated. | +| C | Async managed path, CAS, response-loss resolution, and adapter-owned multipart pass pinned MinIO; the AWS lane is compile-only/authority-pending with no observed evidence. | Rebind only new destinations to the prior qualified provider; old route revisions stay readable. | +| D | Direct grant/session ledgers pass expiry, redaction, completion, and late-request races; no public endpoint is implied. | Stop issuing grants, drain/expire/reconcile existing sessions, keep managed transfer. | +| E | Database intent precedes remote mutation; every crash gap is tested; no unscanned data becomes public; old locators remain dual-readable only during migration. | Stop new admission, drain intents, preserve published-reference reads, do not delete legacy data. | +| F | Each claimed card has exact non-skipping security/fault/real-provider evidence and runbooks. | Disable card admission or maintenance delete, retain published reads and the manual reconciliation queue. | + +Every checkpoint named below has three inseparable outputs: (1) the stated GREEN commands and +zero-selected-skip evidence, (2) updated plan/design implementation status and truthful readiness +rows, and (3) a canonical LLM Wiki branch-note update or the exact canonical-vault access block. +This applies to A at Task 5, B at Task 10, C at Task 16, D at Task 19, E at Task 24, and F at +Task 30; a batch is not closed if any output is missing. + +--- + +## Batch A — Phase 0–1: Truth and framework-free contract + +### Task 1: Characterize the legacy boundary without changing behavior + +**Files:** + +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageBehaviorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java` +- Create: + `docs/evidence/object-storage/phase-0-inventory.md` + +- [ ] **Step 1: Record current executable behavior** + +Prove the current contract exactly as-is: + +- repeated `put` to the same caller key overwrites; +- `get` materializes the whole object; +- filesystem returns `file://` and S3 returns `s3://`; +- absent `ca-skeleton.objectstorage.backend` creates a filesystem bean and directory during + application-context construction, before the first `put`; +- S3 `autoCreateBucket=true` can provision at startup; +- Poster calls storage while `TransactionPort.inWrite` is active; +- the controller calls `MultipartFile.getBytes`; +- the response exposes raw key/location; +- Poster deletion does not retire the object. + +- [ ] **Step 2: Verify the characterization baseline** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LegacyObjectStorageBehaviorTest' \ + --tests '*LegacyObjectStorageConfigTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*LegacyPosterImageUploadCharacterizationTest' \ + --console=plain +``` + +Expected: PASS against the current implementation. This is a baseline, not R1/R2 evidence. + +- [ ] **Step 3: Inventory runtime and data dependencies** + +In the evidence document record command output, not an unsupported repository-wide conclusion: + +```bash +rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \ + src docs +rg -n 'image_key|posters/.*/image' src/sample-portfolio +``` + +Inventory the known producers/consumers and owner evidence for +`poster.image-attached`, `/posters/{id}/image`, `StoredObjectResponse`, and +`PosterResponse.imageKey`. Repository search proves only repository usages; unknown external +broker/REST consumers are recorded as unknown and block Gate A removal/versioning approval. + +Classify every hit as legacy runtime, test, documentation, stored-data schema, or unrelated text. +Record whether real deployed consumers/data were inspected; if they were not, state that external +inventory is still required. + +- [ ] **Step 4: Run unchanged focused suites** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain +``` + +Expected: PASS. No source behavior changes belong to this task. + +### Task 2: Add bounded object-storage identities and opaque references + +**Files:** + +- Modify: + `src/application-core/build.gradle` +- Modify: + `src/application-core/gradle.lockfile` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/identity/`: + `ObjectDestinationId.java`, `ObjectOperationEpoch.java`, `ObjectOperationId.java`, + `ObjectOperationKey.java`, `ObjectId.java`, `ObjectReference.java`, `ObjectStageHandle.java`, + `ObjectVersionToken.java`, `DirectTransferSessionId.java`, `MultipartPartNumber.java`, + `PartReceiptToken.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageIdentityContractTest.java` + +- [ ] **Step 1: Add the approved test-only property engine** + +Add `testImplementation 'net.jqwik:jqwik:1.9.1'`, matching the existing sample test version, and +update only the application-core lockfile: + +```bash +cd src +./gradlew :application-core:resolveAndLockAll --write-locks +./gradlew :application-core:verifyDependencyLocks --console=plain +``` + +Expected: PASS with test-only jqwik entries and no production dependency. + +- [ ] **Step 2: Write the failing identity contract** + +Test null/blank/control-character/oversize rejection, canonical round trips, operation-key +composition, part range `1..10_000`, prefix separation, route-token grammar, reference check-digit +tampering, and provider-locator non-disclosure. Include jqwik properties for arbitrary malformed +input and a fixed golden vector for each prefix. Route existence/retirement is adapter binding +state and is deliberately not tested or imported in `application-core`. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStorageIdentityContractTest' --console=plain +``` + +Expected: compilation failure because the new identity types do not exist. + +- [ ] **Step 4: Implement the minimum values** + +Use immutable final classes or records with constructor validation. `ObjectOperationKey` contains +exactly destination, epoch, and operation ID. Public reference/handle types expose canonical text +and a redacted log token only; they do not expose parsed provider coordinates. Keep check-digit +validation in framework-free Java (`MessageDigest`). The adapter-owned codec in Task 6 is the only +minting path from a retained route token plus generated `ObjectId`; application values do not +consult a route registry. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3 and +`./gradlew :application-core:verifyDependencyLocks --console=plain`. Expected: PASS. + +### Task 3: Add bounded streaming, digest, range, and cancellation contracts + +**Files:** + +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/content/`: + `ObjectContentProducer.java`, `ObjectChunkSink.java`, `ObjectContentConsumer.java`, + `ObjectChunkSource.java`, `ObjectContentProductionContext.java`, + `ObjectContentReadContext.java`, `CancellationView.java`, + `ObjectContentProductionException.java`, `ObjectChunkWriteException.java`, + `ObjectContentConsumptionException.java`, `ObjectChunkReadException.java` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/`: + `ObjectDigestAlgorithm.java`, `ObjectDigest.java`, `ObjectContentIdentity.java`, + `ObjectMediaType.java`, `ObjectReadRange.java`, `ObjectDigestVerification.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectContentContractTest.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageValueContractTest.java` + +- [ ] **Step 1: Write failing callback and value tests** + +Prove: + +- source EOF is `-1`, zero-length calls and array ranges are validated, and bounded repeated + zero-progress reads fail; +- sink/source cannot be retained and used after callback return; +- contexts carry `CallBudget`, `CancellationView`, maximum chunk bytes, and validated read + descriptor/range only; +- SHA-256 is the baseline logical digest and is distinct from provider transport checksum/ETag; +- exact length plus digest is required for R2 `ObjectContentIdentity`; +- range offset/length arithmetic rejects zero, negative, overflow, and over-budget delivery; +- media types are canonical, bounded, and contain no control characters. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectContentContractTest' \ + --tests '*ObjectStorageValueContractTest' --console=plain +``` + +Expected: compilation failure because the callback/value types do not exist. + +- [ ] **Step 3: Implement the minimum contract** + +Reuse `CallBudget`; do not create serializable wall-clock deadlines. Callback types may declare only +application exceptions and Java primitive/array types. Do not expose `InputStream`, `OutputStream`, +`ByteBuffer`, Reactor, Flow, servlet, Spring, or AWS types. Document that callbacks are synchronous +and blocking and that adapters must not invoke application callbacks on SDK event-loop threads. + +- [ ] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +### Task 4: Add the semantic request, receipt, outcome, and port family + +**Files:** + +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/port/`: + `ManagedObjectPublicationPort.java`, `ObjectInspectionPort.java`, + `ObjectTransferPort.java`, `ObjectRetirementPort.java`, + `ObjectPurgeMaintenancePort.java`, `ObjectOperationResolutionPort.java`, + `ObjectPublicationHandoffPort.java`, `DirectObjectUploadPort.java`, + `DirectObjectDownloadGrantPort.java`, `DirectMultipartUploadPort.java`, + `StagedObjectPublicationPort.java`, `ObjectScanMaintenancePort.java` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/request/`: + `ObjectPublishRequest.java`, `ObjectReadRequest.java`, `ObjectRetireRequest.java`, + `ObjectPurgeRequest.java`, `ObjectStageRequest.java`, `ObjectVerifyRequest.java`, + `ObjectScanReadRequest.java`, `ObjectScanVerdictRequest.java`, `ObjectFinalizeRequest.java`, + `ObjectAbortRequest.java`, `ObjectHandoffClaimRequest.java`, + `ObjectHandoffRenewRequest.java`, `ObjectHandoffReleaseRequest.java`, + `ObjectAbortAuthorization.java`, `DirectUploadGrantRequest.java`, + `DirectUploadCompletionRequest.java`, `DirectDownloadGrantRequest.java`, + `MultipartStartRequest.java`, `PartUploadGrantRequest.java`, + `MultipartPartAcknowledgement.java`, `MultipartCompleteRequest.java`, + `MultipartAbortRequest.java` +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/objectstorage/model/`: + `ObjectDescriptor.java`, `ObjectPublishReceipt.java`, `ObjectReadReceipt.java`, + `ObjectMutationReceipt.java`, `ObjectOperationResolution.java`, + `ObjectStageReceipt.java`, `ObjectVerificationReceipt.java`, + `ObjectHandoffReceipt.java`, `DirectUploadGrant.java`, + `DirectUploadCompletionReceipt.java`, `DirectDownloadGrant.java`, + `MultipartSession.java`, `PartUploadGrant.java`, `MultipartReceipt.java`, + `ObjectMutationOutcome.java`, `ObjectOperationError.java`, + `ObjectPublicationState.java`, `ObjectScanState.java`, + `ObjectPublicationRequirement.java`, `ObjectRetentionRequirement.java`, + `ObjectEncryptionRequirement.java`, `ObjectCapabilityRequirement.java` +- Test: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStoragePortContractTest.java` + +- [ ] **Step 1: Write the failing port-shape contract** + +Use reflection and construction tests to prove: + +- every mutation request contains `ObjectOperationKey`; +- inspect/transfer require opaque published references and cannot accept stage handles; +- purge is a distinct port from business retirement; +- direct completion verifies rather than trusting a client success flag; +- multipart completion accepts only server-issued part tokens; +- staged finalization is the only staged operation that returns an `ObjectReference`; +- scan verdict binds exact stage/version, scanner policy revision, and scan operation; +- `StagedObjectPublicationPort` has no scan-read/verdict methods; + `ObjectScanMaintenancePort` alone owns unpublished exact-version transfer and verdict recording, + and neither normal publication nor purge port is assignable to it; +- receipts expose no locator, ETag, upload ID, URL, credential, or provider enum; +- requirements can strengthen but never lower destination policy. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStoragePortContractTest' --console=plain +``` + +Expected: compilation failure because the semantic port family does not exist. + +- [ ] **Step 3: Implement the minimum framework-free API** + +Follow design §9 exactly. Keep one public top-level type per file. Use immutable collections and +defensive copies where required. Grants contain a bounded URI, signed header names/values, +expiration, and opaque session identity, but their `toString` must redact the URI and headers. +`ObjectPurgeMaintenancePort` documentation must state its privileged composition boundary. +Document the separate scanner-workflow composition boundary on `ObjectScanMaintenancePort`; the +normal staged port owns only stage, integrity verification, finalize, and abort. + +- [ ] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. + +### Task 5: Enforce application purity and isolate the legacy compatibility seam + +**Files:** + +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java` +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java` +- Create: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` + +- [ ] **Step 1: Write failing purity tests** + +Assert recursively through fields, methods, constructors, record components, generic arguments, and +annotations that `dev.caskeleton.application.objectstorage..` has no Spring, AWS SDK, servlet, +transport DTO, `Path`, `File`, persistent provider locator, JPA, SLF4J, or adapter type. Permit +`java.net.URI` only in the explicit direct-grant values and prove their redacted/persistence +boundary. Add an ArchUnit rule that new sample business code may not import +`dev.caskeleton.application.storage`; freeze the current upload use case until Task 23 splits it +into an explicitly named legacy-only package, and permit the later exact +`application.storage.migration` adoption use case as an admin-only compatibility exception. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStorageArchitectureContractTest' --console=plain +./gradlew :app-bootstrap:test \ + --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: the new sample-import rule initially identifies the current legacy consumer, or the test +fixture explicitly records it as the single frozen violation. No unrelated architecture violation +may be accepted. + +- [ ] **Step 3: Deprecate without adapting new calls back to raw keys** + +Mark both legacy types `@Deprecated(forRemoval = true)` and document: + +- legacy overwrite/materialization semantics; +- separate legacy namespace; +- production-disabled target state; +- no use from new code; +- removal gates, not an invented removal date. + +Freeze the existing `UploadPosterImageUseCase` as the only temporary sample violation until Task 23. +Task 23 must move the remaining compatibility surface into an allowlisted `..poster.legacy..` +slice; no non-legacy sample package may import the old port after that cutover. Do not create a +semantic-to-legacy adapter that throws away operation identity or guarantees. + +- [ ] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS with exactly the named frozen legacy violation and zero +provider/framework leaks. The later migration exception must be bounded/redacted and visible only +to the named administrative use case. + +- [ ] **Step 5: Run Batch A checkpoint** + +Run: + +```bash +cd src +./gradlew :application-core:check --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +Expected: PASS. Update readiness documentation to R0 contract only. Do not call this an implemented +object-storage provider. + +--- + +## Batch B — Phase 2: Provider-neutral kernel and local R1 + +### Task 6: Add canonical namespace, reference, fingerprint, and policy codecs + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/`: + `ObjectDataKeyCodec.java`, `ObjectControlKeyCodec.java`, + `ObjectReferenceCodec.java`, `ObjectHandleCodec.java`, + `ObjectRequestFingerprintCodec.java`, `ObjectPolicySnapshotCodec.java`, + `CrockfordBase32.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`: + `ObjectBindingRevision.java`, `ObjectPolicyRevision.java`, `ObjectRouteToken.java`, + `ObjectPolicySnapshot.java`, `ObjectOperationEpochRecord.java`, + `ObjectOperationEpochState.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectRequestFingerprintCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationEpochTest.java` + +- [ ] **Step 1: Add the approved test-only property engine** + +Add `testImplementation 'net.jqwik:jqwik:1.9.1'` and update only this leaf's lockfile: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +``` + +Expected: PASS with no production jqwik dependency. + +- [ ] **Step 2: Write failing codec and epoch tests** + +Use golden vectors and property tests for: + +- `data/v1` and every `control/v1` grammar in design §10; +- ASCII-only segments, fixed maximum segment/total length, deterministic shard, and rejection of + slash aliases, percent encoding, Unicode normalization ambiguity, `.`/`..`, and control + characters; +- data-key APIs accept only generated `ObjectId`, route, generation, and typed revision values, + never a filename/tenant/raw-name `String`; compile/static contract tests prove that representative + email/filename values have no accepted parameter path instead of attempting a PII heuristic; +- the exact `osr1`/`osh1`/`osu1`/`osm1` grammar frozen above; +- canonical fingerprint field order, absence-versus-empty, integer overflow, enum names, and + schema version; +- same intent yielding the same fingerprint and any semantic field change yielding a different + fingerprint; +- binding/policy snapshots that contain no secret or provider credential; +- epoch `WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED` transitions, no token reuse, and + `OPERATION_EXPIRED` after seal/compaction. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectNamespaceCodecTest' \ + --tests '*ObjectRequestFingerprintCodecTest' \ + --tests '*ObjectOperationEpochTest' --console=plain +``` + +Expected: compilation failure because the codec/kernel types do not exist. + +- [ ] **Step 4: Implement deterministic codecs** + +Keep all physical key construction in these codecs. Route lookup uses retained binding revision, +never a current-provider default. The fingerprint includes exact content identity for R2 and an +explicit `R1_UNVERIFIED_CONTENT` marker for compatibility; it never hashes content by materializing +the object. Operation epoch records include finite replay/retention/compaction bounds. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3 and +`./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain`. Expected: PASS. + +### Task 7: Add strict control records and provider-neutral operation state machines + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/`: + `ObjectControlRecord.java`, `ObjectOperationRecord.java`, + `ObjectManifestRecord.java`, `ObjectReferencePointerRecord.java`, + `ObjectReferenceRecord.java`, `ObjectMultipartSessionRecord.java`, + `ObjectMultipartPartRecord.java`, `ObjectControlRecordEnvelope.java`, + `ObjectControlRecordCodec.java`, `CanonicalJsonObjectControlRecordCodec.java`, + `CanonicalJsonReader.java`, `CanonicalJsonWriter.java`, + `ObjectControlStore.java`, `ObjectControlVersion.java`, + `ObjectControlMutation.java`, `ObjectControlConflictException.java`, + `ObjectControlCorruptionException.java`, `UnsupportedObjectControlSchemaException.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`: + `PublicationOperationState.java`, `ScanOperationState.java`, + `PublishedReferenceState.java`, `DirectGrantSessionState.java`, + `MultipartUploadState.java`, `PendingObjectEffect.java`, + `ObjectEffectCertainty.java`, `ObjectOperationStateMachine.java`, + `ObjectOperationKernel.java`, `ObjectOperationKernelResult.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernelTest.java` + +- [ ] **Step 1: Write failing strict-codec tests** + +For every record family introduced through Task 7 prove: + +- canonical byte-for-byte round trip and checked-in golden fixture; +- schema v1 only for writes; +- duplicate, unknown, missing, reordered-invalid, truncated, oversized, checksum-mismatched, and + newer-schema records fail closed rather than appearing absent; +- operation state and revision invariants; +- frozen route/provider/binding/policy/codec/checksum/encryption/retention revisions; +- separate expected/observed content digest and provider ETag/checksum evidence; +- no secret, URL, raw credential, original filename, public ACL, absolute path, or inbound data; +- independent part records, with no unbounded in-session map. + +Also prove an immutable manifest revision binds `ObjectId`, exact provider version, logical +size/digest/media type, encryption/retention evidence, and immutable data version, while a separate +small reference pointer CASes only the current manifest revision. No mutable pointer is treated as +the manifest itself. + +- [ ] **Step 2: Write failing transition-table tests** + +Cover every allowed and forbidden transition from design §12, including: + +- reservation, pending effect before I/O, evidence-based certainty after I/O; +- terminal same-fingerprint replay without producer invocation; +- any-state different-fingerprint conflict; +- response loss yielding `INDETERMINATE` until resolution; +- scan, published-reference, direct-session, and multipart states remaining independent; +- stale fence/revision rejection; +- unknown/newer state never auto-deleted or downgraded. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*ObjectOperationStateMachineTest' \ + --tests '*ObjectOperationKernelTest' --console=plain +``` + +Expected: compilation failure because the control-plane and state-machine types do not exist. + +- [ ] **Step 4: Implement the minimum kernel** + +The kernel accepts a compiled policy snapshot and an `ObjectControlStore`; it does not import a +provider SDK. Reserve writes the frozen snapshot before provider mutation. Every mutation writes a +pending-effect attempt before I/O and resolves from exact evidence after I/O. Implement +`canonical-json-v1` with the named bounded JDK-only reader/writer and an explicit closed family +discriminator; no reflective or `Map` binding and no undecided JSON dependency is +allowed. A later task that adds a durable family must modify this codec, add checked-in golden +bytes, and prove old/new reader compatibility before the selected write version changes. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 8: Implement the bounded `filesystem-local-dev` provider + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/provider/`: + `ObjectStorageProvider.java`, `ObjectStorageProviderDescriptor.java`, + `ObjectStorageProviderOperation.java`, `ObjectStorageProviderException.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/`: + `LocalDevObjectStorageProvider.java`, `LocalDevObjectControlStore.java`, + `LocalDevObjectDataStore.java`, `LocalObjectPathGuard.java`, + `LocalObjectStreamTransfer.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/contract/ObjectStorageProviderContract.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProviderTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageRecoveryTest.java` + +- [ ] **Step 1: Write the reusable provider contract** + +The abstract suite must be executable for each provider and cover: + +- empty, one-byte, chunk-minus-one, exact-chunk, chunk-plus-one, and maximum-size upload; +- bounded producer invocation and no full-object buffer; +- immutable create/conflict and terminal replay; +- exact inspect/version/digest; +- full and one contiguous range read; +- short/failing/stalled producer and slow/failing consumer; +- cancellation before and during transfer; +- checksum match/mismatch; +- conditional retirement; +- response-loss resolution; +- callback/resource invalidation and closure. + +Unsupported optional capabilities must assert descriptor `UNSUPPORTED`, not skip. + +- [ ] **Step 2: Write failing local security/recovery tests** + +Use `@TempDir` and injected filesystem/fault collaborators to test: + +- traversal, absolute path, Unicode alias, root escape, symlink root/nested/swap; +- exclusive-create race with two writers; +- restrictive created permissions where POSIX exists; +- disk full, permission denied, read-only simulation, short write, truncated read; +- restart after each control/data step; +- same operation recovery without producer replay; +- corrupt/newer records quarantined, never absent/deleted; +- file descriptor and temporary-file cleanup. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LocalDevObjectStorageProviderTest' \ + --tests '*LocalDevObjectStorageRecoveryTest' --console=plain +``` + +Expected: compilation failure because the local provider/kernel integration does not exist. + +- [ ] **Step 4: Implement bounded local R1** + +Use adapter-generated immutable data names, `CREATE_NEW`, bounded chunks, streaming SHA-256, staged +temporary files, force/atomic move only where the host proves it, and strict relative path checks. +The local control store serializes per operation in one process. When portability or crash +durability cannot be proven, return the truthful R1 descriptor; never claim multi-node CAS or +power-loss durability. This provider is rejected in production profiles. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 9: Compile exact settings and compose a disabled-by-default routing capability + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/`: + `ObjectStorageCapabilitySettings.java`, `ObjectStorageProviderSettings.java`, + `ObjectStorageDestinationSettings.java`, `CompiledObjectStorageProvider.java`, + `CompiledObjectStorageDestination.java`, `ObjectStorageBindingCompiler.java`, + `ObjectStorageProviderContribution.java`, `SelectedObjectStorageProviderFactory.java`, + `ObjectStorageCapabilityAssembler.java`, `ObjectStorageCapabilityConfig.java`, + `RoutingObjectReadAdapter.java`, `RoutingObjectMutationAdapter.java`, + `RoutingObjectDirectGrantAdapter.java`, `ObjectStorageMaintenanceCapabilityConfig.java`, + `LegacyObjectStorageActivationGuard.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompilerTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` + +- [ ] **Step 1: Write failing binding tests** + +Bind `app.object-storage` and reject: + +- enabled without providers/destinations or required destination; +- missing/unknown provider ref/type or duplicate normalized IDs; +- any implicit provider, destination, bucket, namespace, root, or capability; +- invalid namespace/size/chunk/part/replay/timeout/retry/amplification bounds; +- destination requirement stronger than provider descriptor; +- route or namespace collision/reuse; +- unknown, retired, or unavailable retained route revision during read/reconcile; +- scan-required destination without the scan seam; +- local-dev selected in a production profile; +- canonical settings and any old `ca-skeleton.objectstorage.*` alias present together; +- legacy and canonical data/control namespaces overlapping. + +Prove a valid local profile compiles one exact route and immutable policy snapshot. + +- [ ] **Step 2: Write failing composition tests** + +Use `ApplicationContextRunner` to prove: + +- absent or `enabled=false` creates zero ports, directory, credential lookup, client, thread, + scheduler, health indicator, and warning; +- enabled explicit local binding creates exactly one routing implementation for each applicable + semantic port; +- an unknown destination fails before producer invocation; +- routing retains old route/binding revisions for reads/reconciliation; +- `matchIfMissing` is gone; +- legacy-only mode remains isolated and opt-in during migration; +- an old `ca-skeleton.objectstorage.*` alias together with any canonical setting fails without + logging property values; +- a namespace-separated canonical `legacy` subgroup plus the new capability may run together only + in the explicit migration/sample-local profile. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageBindingCompilerTest' \ + --tests '*ObjectStorageCapabilityConfigTest' --console=plain +``` + +Expected: compilation/test failure because canonical settings/composition do not exist and current +legacy configuration activates filesystem by default. + +- [ ] **Step 4: Implement exact binding and activation** + +Use immutable constructor-bound settings and typed `Duration`/`DataSize`/enums. Compile settings +before constructing any provider. Contributions are side-effect-free descriptors; the assembler +invokes only the exact selected contribution after successful compilation, so an unselected +provider cannot resolve credentials, construct a client, create a thread, or touch a directory. +Normal read/mutation/direct facades are separate types; scan maintenance and privileged purge are +not implemented by or registered through a normal facade. The routers use immutable maps keyed by +destination and retained route revision and never fall back. Keep legacy configuration behind an explicit +`app.object-storage.legacy.enabled=true` compatibility condition, default false. Old +`ca-skeleton.objectstorage.*` aliases may activate legacy-only mode during the first migration +step, but their presence together with any canonical setting fails. A dual-run profile uses only +the canonical `legacy` subgroup with an explicit backend/root-or-prefix isolated from all v1 +data/control namespaces. + +Until Task 22 migrates the consumer, the sample-local YAML explicitly enables the isolated legacy +filesystem seam and may also select `filesystem-local-dev` at `./.data/object-storage-v1` for the +new capability. No production YAML receives a local fallback. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +- [ ] **Step 6: Prove the stable contribution seam** + +Add an `ApplicationContextRunner` matrix with a counting fake contribution: disabled, unselected, +invalid binding, selected success, selected construction failure, and close. Prove construction +occurs exactly once only after compilation, close occurs exactly once, and there is no privileged +maintenance/purge bean in the normal context. Every later provider task must extend this matrix +when it registers a contribution. + +Re-run the Step 3 command. Expected: PASS; the contribution matrix is part of Task 9 GREEN, not an +unverified post-GREEN addition. + +### Task 10: Publish the truthful local R1 card and close the Phase 2 gate + +**Files:** + +- Create: + `docs/registries/object-storage-readiness.yaml` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessLevel.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java` +- Modify: + `src/adapter/outbound/objectstorage/README.md` +- Modify: + `src/adapter/outbound/objectstorage/CLAUDE.md` +- Modify: + `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md` + +- [ ] **Step 1: Write the failing registry/schema test** + +Validate the frozen schema and exact nine card IDs. Reject: + +- unknown card/level/provider; +- a global “objectstorage R2” row; +- R1/R2 without exact provider version and destination profile; +- R2 without evidence revision/expiry and non-skipping required tasks; +- a required task that is absent from Gradle; +- local-dev above R1; +- a limitation-free row when a provider descriptor reports limitations. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageReadinessRegistryTest' --console=plain +``` + +Expected: failure because the registry and runtime card types do not exist. + +- [ ] **Step 3: Add only evidenced claims** + +Initial entries may claim R0 for contract-only cards and R1 for local managed single upload/download +only after Tasks 6–9 pass. Direct, multipart, quarantine, retention, and production reconciliation +remain R0/unimplemented. Document that local process recovery is not multi-node CAS or R2. + +- [ ] **Step 4: Run Batch B checkpoint** + +Run: + +```bash +cd src +./gradlew \ + :application-core:check \ + :adapter:outbound:objectstorage:check --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +Expected: PASS. + +- [ ] **Step 5: Verify rollback** + +Start a context with the canonical capability disabled and legacy disabled. Assert there are no +storage beans or side effects. Start the explicit legacy profile and prove old data remains +readable. No migration or deletion occurs at startup. + +--- + +## Batch C — Phase 3: Managed S3/MinIO common subset + +### Task 11: Characterize the pinned SDK and add the explicit async HTTP client + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3AsyncClientFactory.java`, `S3ClientPolicy.java`, `S3ClientLifecycle.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3SdkApiCharacterizationTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java` + +- [ ] **Step 1: Write the failing pinned-API characterization** + +Compile and assert the exact `2.30.0` API used by later tasks: + +- `PutObjectRequest.Builder.ifNoneMatch` and `ifMatch`; +- `CompleteMultipartUploadRequest.Builder.ifNoneMatch`, `ifMatch`, and + `mpuObjectSize(Integer)`; +- explicit request/response checksum configuration; +- `expectedBucketOwner` on every relevant request; +- presigner availability without constructing it yet. + +Add boundary tests at `Integer.MAX_VALUE`, `Integer.MAX_VALUE + 1L`, part count 10,000/10,001, and +minimum S3 non-final part size. Record the full-object multipart checksum profile as unsupported +above the SDK integer boundary unless an independently approved path exists. + +- [ ] **Step 2: Write failing client-policy tests** + +Reject missing/non-positive/contradictory: + +- parent API call and per-attempt timeout; +- connect, TLS negotiation, acquire, read, and write timeout; +- max concurrency and pending acquire bounds; +- SDK retry attempts/backoff that exceed the parent budget; +- shutdown grace; +- plaintext AWS endpoint, endpoint userinfo/query/fragment, and partial static credentials. + +Assert the factory uses `S3AsyncClient` plus explicit `NettyNioAsyncHttpClient`, not CRT or the sync +client, and owns close order. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3SdkApiCharacterizationTest' \ + --tests '*S3AsyncClientFactoryTest' --console=plain +``` + +Expected: compilation failure because the client factory and compile-scoped Netty async client do +not exist. + +- [ ] **Step 4: Add only the required dependency and implementation** + +Add `software.amazon.awssdk:netty-nio-client` under the existing AWS BOM. Keep +`software.amazon.awssdk:s3` and the BOM at `2.30.0`; do not add CRT or Transfer Manager. Configure +finite client and HTTP timeouts/pools/retry from the compiled policy. Default-chain credentials are +resolved only after the provider is selected. + +- [ ] **Step 5: Regenerate and verify locks** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3SdkApiCharacterizationTest' \ + --tests '*S3AsyncClientFactoryTest' --console=plain +``` + +Expected: PASS, with only reviewed async-client transitive changes in the module lockfile. + +### Task 12: Compile exact AWS and MinIO provider bindings and qualification descriptors + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3ProviderSettings.java`, `S3ProviderBinding.java`, `S3ProviderType.java`, + `S3ProviderVersion.java`, `S3ProviderErrorMapper.java`, + `S3CapabilityProbe.java`, `S3ProviderQualifier.java`, + `S3QualificationEvidence.java`, `S3ObjectStorageProviderContribution.java`, + `S3ProviderCapabilityConfig.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/`: + `ObjectStorageCapabilityDescriptor.java`, `CapabilityEvidence.java`, + `CapabilityEvidenceStatus.java`, `CapabilityEvidenceSource.java`, + `ObjectStorageOperationProfile.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderSettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBindingTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderQualifierTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapperTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` + +- [ ] **Step 1: Write failing exact-binding tests** + +Accept only: + +- `aws-s3-general-purpose`; +- `s3-compatible-minio-community-release-2024-01-16t16-07-38z`. + +Reject `s3`, `s3-compatible`, unknown version, directory bucket/S3 Express, MRAP/access point, +auto-create, public ACL, plaintext production endpoint, missing AWS expected owner, missing MinIO +deployment identity, namespace collision, static production credentials, unbounded budgets, and a +destination whose exact named profile has no unexpired `SUPPORTED` evidence. + +Do not derive one combined profile by AND-ing unrelated booleans. +Use the Task 9 contribution seam to prove disabled, unselected, and invalid bindings create no +client, credential lookup, DNS, executor, or probe; only a selected, fully compiled exact provider +constructs one lifecycle-owned client and closes it exactly once. + +- [ ] **Step 2: Write failing qualifier/error tests** + +Prove normalized mapping for permission, owner/region mismatch, `404`, `409`, `412`, throttling, +timeout, checksum mismatch, retention/hold, and unknown response loss. A final SDK exception alone +must not turn a mutation into an authoritative failure. Safe probe mode may access only its reserved +prefix and may not create a bucket or change versioning/lifecycle/CORS/ownership/BPA/encryption. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3ProviderBindingTest' \ + --tests '*S3ProviderQualifierTest' \ + --tests '*S3ProviderErrorMapperTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because exact S3 provider models and qualification do not exist. + +- [ ] **Step 4: Implement the minimum descriptors** + +Descriptor axes and named profiles follow design §21. Qualification sources are +`STATIC_ATTESTATION`, `STARTUP_PROBE`, or `CI_QUALIFICATION`, each with digest, provider/deployment +identity, observation/expiry, and limitations. `UNVERIFIABLE` and expired evidence never compile as +supported. Keep AWS/MinIO behavior separate behind the same provider-neutral kernel. Register the +side-effect-free S3 contribution explicitly; do not use component scanning as activation. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 13: Implement bounded managed single upload, inspect, download, and range + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3ManagedObjectProvider.java`, `S3AsyncRequestBodyBridge.java`, + `S3AsyncResponseBodyBridge.java`, `S3ObjectEvidenceMapper.java`, + `S3ChecksumPolicy.java`, `S3ConditionalRequestMapper.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridgeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridgeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedObjectProviderTest.java` + +- [ ] **Step 1: Write failing bridge tests** + +With deterministic executors and a fake async subscriber/publisher, prove: + +- aggregate buffers never exceed configured chunks/bytes; +- producer/consumer runs off the SDK event-loop; +- backpressure prevents unbounded producer lead; +- single-pass producer is invoked once; +- cancellation, callback failure, subscriber cancellation, short/zero-progress/truncated body, and + deadline expiry close resources and release admission; +- logical SHA-256 is computed while streaming and checked independently of provider checksum; +- no whole-object `byte[]`, `toBytes`, `getObjectAsBytes`, or `RequestBody.fromBytes` path exists. + +- [ ] **Step 2: Write failing provider tests** + +Mock only the SDK boundary and prove exact request mapping: + +- immutable single PUT uses `If-None-Match: *`, exact content length/checksum/encryption/owner; +- HEAD validates size, version, checksum, encryption, and publication record; +- GET/range validates returned version, range, content length/range, and digest mode; +- empty object is valid; +- `409`/`412`, permission, absence, throttling, and response loss map to distinct outcomes; +- public receipts contain no S3 locator/evidence. +- the selected S3 contribution exposes managed single/inspect/download/range delegates through the + normal routers, while unselected/disabled contexts still expose none and create no resources. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3AsyncRequestBodyBridgeTest' \ + --tests '*S3AsyncResponseBodyBridgeTest' \ + --tests '*S3ManagedObjectProviderTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because the async bridges/provider do not exist. + +- [ ] **Step 4: Implement the minimum managed path** + +Bridge the synchronous application callbacks through a bounded adapter-owned worker and queue. +Propagate cancellation to the SDK future/body, invalidate callback resources, and release every +semaphore/buffer. Do not let SDK retry replay a non-repeatable producer. If transport retry requires +body replay, resolve evidence or require a new operation; an adapter spool needs a separate plan. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 14: Implement S3 conditional control storage and response-loss resolution + +**Files:** + +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolver.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationResolutionService.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStoreTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectOperationResolverTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectMutationResponseLossTest.java` + +- [ ] **Step 1: Write failing CAS tests** + +Prove exact-key direct lookup and: + +- reserve with `If-None-Match: *`; +- update with the record's exact private ETag in `If-Match`; +- stale writer and same-operation/different-fingerprint conflict; +- dropped create/update response reconciled by GET and record digest/revision comparison; +- `404` is authoritative only after the operation-specific evidence rules permit it; +- corrupt/newer record never becomes absent or overwritten; +- LIST is not used on the request path. + +- [ ] **Step 2: Write the failing response-loss matrix** + +Inject loss after operation reserve, data PUT, data HEAD, reference create, terminal record CAS, and +retirement. Expected outcomes must be terminal replay, deterministic continuation, typed conflict, +or `INDETERMINATE`; never blind duplicate mutation or producer replay. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*S3ConditionalObjectControlStoreTest' \ + --tests '*S3ObjectOperationResolverTest' \ + --tests '*ObjectMutationResponseLossTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation/failing reconciliation because S3 CAS/resolution does not exist. + +- [ ] **Step 4: Implement conditional storage and resolution** + +Persist pending effect, attempt ID, exact precondition, and request-evidence digest before each +mutation. Use frozen binding/policy revision for resolution. Provider ETag remains private and is +not treated as logical content digest or public version. Unsupported conditional semantics fail +provider qualification; do not emulate with HEAD-then-unconditional-PUT. Wire the conditional store +and resolver into only the selected S3 contribution and retain the disabled/unselected zero-effect +composition assertions. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 15: Implement adapter-owned managed multipart and a sharded part ledger + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/`: + `ManagedMultipartCoordinator.java`, `MultipartUploadPlan.java`, + `MultipartPartLedger.java`, `MultipartCompletionEvidence.java`, + `MultipartOperationResolver.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProvider.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/ManagedMultipartCoordinatorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ManagedMultipartProviderTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartResponseLossTest.java` + +- [ ] **Step 1: Write failing plan/ledger tests** + +Prove: + +- single PUT versus multipart threshold is deterministic and frozen; +- S3 part minimum, final-part exception, max 10,000 parts, per-part/in-flight/concurrency budgets; +- each completed part is an immutable bounded control record; +- duplicate same part evidence replays; different evidence conflicts; +- ordered completion derives only from the server ledger; +- full logical SHA-256 remains separate from part/provider checksums; +- SDK `mpuObjectSize(Integer)` overflow rejects the incompatible checksum profile before I/O. + +- [ ] **Step 2: Write failing provider/recovery tests** + +Cover create, upload part, list parts, complete, abort, `404`/`409`/`412`, stale upload ID, dropped +part response, dropped complete response, concurrent abort/complete, process restart, and orphan +candidate production. Completion after a `409` that requires a new upload must not retry the old +upload ID. Before `CreateMultipartUpload`, require an `INITIATE_IN_PROGRESS` control CAS with a +pending effect, deterministic operation-exclusive data key, and attempt evidence. Inject the fault +where S3 creates the upload ID but the response is lost: bounded, paginated discovery by that exact +key/attempt horizon may adopt one unambiguous upload; zero/multiple/unprovable candidates remain +`INDETERMINATE` orphan evidence and must not trigger a blind second initiate. + +Prove create-only completion uses `If-None-Match: *` on +`CompleteMultipartUpload`, with distinct `404`/`409`/`412` outcomes. SSE-KMS/DSSE and Object Lock +headers belong on `CreateMultipartUpload`; `UploadPart`/complete receive only operation-appropriate +checksum/owner/precondition fields, not copied PUT-only KMS headers. An Object-Lock request includes +the provider-required `Content-MD5` or an exact qualified checksum. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ManagedMultipartCoordinatorTest' \ + --tests '*S3ManagedMultipartProviderTest' \ + --tests '*MultipartResponseLossTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because managed multipart types do not exist. + +- [ ] **Step 4: Implement low-level multipart only** + +Call `CreateMultipartUpload`, `UploadPart`, `ListParts`, `CompleteMultipartUpload`, and +`AbortMultipartUpload` directly. Never delegate R2 state to SDK automatic multipart. Keep upload ID +and part ETags private. Do not send create until `INITIATE_IN_PROGRESS` is durable, and do not +re-initiate while its outcome is uncertain. Register the managed multipart delegate and its closed +record-family codec explicitly in the selected S3 contribution; add golden/old-reader fixtures for +every new durable field. Abort eligibility is recorded but physical cleanup remains report-only +until Task 27; destructive provider qualification is Task 29 and requires explicit authorization. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 16: Qualify the pinned MinIO managed subset and prepare the protected AWS lane + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/resources/object-storage/minio-provider-evidence.json` +- Create: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` +- Modify: + `.github/scripts/verify-gate-matrix.sh` +- Modify: + `docs/registries/object-storage-readiness.yaml` + +- [ ] **Step 1: Add non-skipping Gradle lanes** + +Register: + +```text +objectStorageMinioContractTest +objectStorageMinioFaultTest +objectStorageAwsQualificationTest +``` + +The first two require Docker and fail with an actionable prerequisite message when unavailable. +The AWS task requires explicit sandbox enablement, account/region/bucket/owner inputs, and fails +when selected inputs are absent. Keep the existing developer-fast `disabledWithoutDocker` legacy +test, but never cite it as readiness evidence. + +Regenerate/review the leaf lock after adding the resolvable source-set configurations: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +``` + +Add a secret-free PR/container MinIO contract job and a scheduled/manual MinIO fault job to the +gate matrix. The protected AWS job is declared but cannot execute or emit evidence until Approval +Gate B supplies authority. Artifacts contain normalized results/image digests only—never generated +credentials, endpoints, account IDs, or signed requests. + +- [ ] **Step 2: Write the failing MinIO contract/fault tests** + +Use the exact MinIO release frozen above, pinned by image digest before GREEN. Use +`ghcr.io/shopify/toxiproxy:2.12.0` pinned by digest for TCP latency/reset/bandwidth faults. Generate +test credentials at runtime rather than keeping known literals in source. + +Run the shared provider suite plus: + +- actual conditional create/CAS; +- checksum/HEAD/range; +- managed multipart/abort/list/complete; +- response drop after data/control mutation; +- connection cut, slow body, process restart; +- concurrent same/different fingerprint; +- bounded heap/direct memory/thread/FD assertions. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew \ + :adapter:outbound:objectstorage:objectStorageMinioContractTest \ + :adapter:outbound:objectstorage:objectStorageMinioFaultTest \ + --console=plain +``` + +Expected: failure until images are digest-pinned, the shared suite is wired, and all required +semantics pass. Docker absence is a failure, not success/skip. + +- [ ] **Step 4: Implement only missing provider semantics** + +Do not weaken the contract for MinIO. If the exact release cannot prove a conditional, checksum, +multipart, or recovery behavior, record that operation profile as `UNSUPPORTED`/`UNVERIFIABLE` and +keep the corresponding binding/card disabled. + +- [ ] **Step 5: Re-run the required MinIO lanes GREEN** + +Run the command from Step 3. Expected: PASS for every advertised exact profile, with zero selected +test skips. Any unsupported profile is absent from the selected contract matrix and is asserted as +`UNSUPPORTED` by a separate test; it is not hidden by a skip. + +- [ ] **Step 6: Compile, but do not execute, the protected AWS common-subset lane** + +Compile its source set without contacting AWS: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain +bash ../.github/scripts/verify-gate-matrix.sh +``` + +Expected: PASS with no AWS evidence row. If a registry placeholder is necessary, keep it at R0 +with `limitation: authority_pending` and no observed-evidence fields; do not invent a new evidence +status. Task 29, after Approval Gate B, executes this test with security/fault qualification. Plan +approval alone is not authority to mutate an external bucket. + +- [ ] **Step 7: Run Batch C checkpoint** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:check --console=plain +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +``` + +Expected: PASS. Publish at most exact MinIO R1 managed cards; publish no AWS claim or observed +evidence yet. Preserve old route/binding readers before enabling any new destination writer. + +--- + +## Batch D — Phase 4: Direct transfer and multipart + +This batch implements provider/application primitives and qualification surfaces only. A public +signing/direct endpoint remains out of scope and requires a separate follow-up plan. + +### Task 17: Implement presigned single-upload and exact-version download grants + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/`: + `DirectTransferSessionRecord.java`, `DirectTransferPolicy.java`, + `DirectGrantGeneration.java`, `DirectTransferCoordinator.java`, + `PresignedGrantRedactor.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3PresignerFactory.java`, `S3DirectTransferProvider.java`, + `S3DirectCompletionVerifier.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinatorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProviderTest.java` + +- [ ] **Step 1: Write failing grant-policy tests** + +Reject: + +- non-HTTPS public presign endpoint outside explicit local-test profile; +- userinfo/query/fragment in configured endpoint or host outside allowlist; +- expiry zero/negative/over maximum, beyond the attested `s3:signatureAge` bound, or at/after + `credential/session horizon - qualified maximum clock skew`; +- missing exact operation/content identity, checksum, media type, encryption/retention header, or + create-only precondition required by the named profile; +- a named `direct-single-hard-ceiling` profile when the destination cannot prove a + provider-enforced hard size ceiling; +- an unhealthy/unqualified local clock or NTP status before any new grant; +- direct download before application authorization or for unpublished/retired/wrong-version data. + +- [ ] **Step 2: Write failing lifecycle and redaction tests** + +Prove the exact grant linearization: + +```text +session revision CAS -> GRANT_PREPARED( + constraintsDigest, signingTime, expiresAt, credentialRevision, referenceRevision) +sign exact request +same-revision CAS -> GRANT_ISSUED +return bearer URI +``` + +The `GRANT_PREPARED` CAS must precede signing; `GRANT_ISSUED` is preconditioned on that exact +generation/reference revision and must precede response. Also prove: + +- a lost grant response may reissue only under the frozen generation policy; +- multiple outstanding generations are bounded and tracked through expiry plus in-flight horizon; +- completion ignores a client “success” boolean and performs exact HEAD/checksum/size/version/ + encryption verification; +- retirement CAS linearizes before issuing a download grant; +- retirement winning before the issued CAS discards the signed URL and returns no grant; issued CAS + winning first means the already-issued URL truthfully remains valid until expiry and is not + relabeled “not issued” even if its response is lost; +- URI, query signature, signed header values, credential scope, bucket/key, and session internals + never appear in `toString`, logs, traces, exceptions, metrics, or control records. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*DirectTransferCoordinatorTest' \ + --tests '*PresignedGrantRedactionTest' \ + --tests '*S3DirectTransferProviderTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because direct coordination/provider types do not exist. + +- [ ] **Step 4: Implement the minimum direct single path** + +Use `S3Presigner` owned by the provider lifecycle. Sign only an exact method, key, checksum/content +headers, encryption/retention headers, and bounded expiry required by the compiled profile. Treat +the URL as a bearer secret. Persist the new direct-session family through the closed control codec +with golden/old-reader fixtures, and wire the direct delegate only through the selected S3 +contribution. Completion creates a stage receipt only after evidence verification; publication +remains a separate state transition. + +Keep two explicit profiles rather than one contradictory rule: +`direct-single-hard-ceiling` is rejected unless the provider enforces the ceiling; +`direct-single-soft-limit-r1` may be enabled only with a documented maximum exposure, immediate +post-upload verification/quarantine, and an R1 ceiling. Do not claim that a post-upload HEAD +prevents temporary oversized storage. POST policy remains unsupported until a separately audited +signer is added. New grant admission fails closed when qualified clock health is unavailable. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 18: Implement direct multipart grants, acknowledgement, completion, and abort + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/`: + `DirectMultipartCoordinator.java`, `DirectMultipartGrantLedger.java`, + `DirectPartAcknowledgementVerifier.java`, `DirectMultipartCompletionVerifier.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartRaceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProviderTest.java` + +- [ ] **Step 1: Write failing session/part tests** + +Prove: + +- start first CASes `INITIATE_IN_PROGRESS` plus exact pending-effect evidence, then calls create; + provider-accepted/create-response-lost recovery uses bounded, paginated, exact operation-key + discovery and never blindly initiates again; ambiguity remains `INDETERMINATE`/orphan; +- a confirmed start persists the provider upload ID privately before returning a session; +- part grants are bounded by part number/count/size/concurrency/expiry and server generation; +- acknowledgement accepts only an allowlisted, bounded provider completion claim and converts it to + an opaque `PartReceiptToken`; +- a reissued part grant cannot let a stale late request silently replace an acknowledged part; +- completion closes grant/ack admission, waits for every issued generation expiry plus qualified + clock skew and maximum in-flight horizon (or proves controlled-ingress drain), then paginates + `ListParts`; +- completion compares each current ledger revision/token against exact provider part number, + private ETag, checksum algorithm/type/scope, and length before a `COMPLETE_IN_PROGRESS` CAS; +- conditional complete is followed by exact final version/size and `FULL_OBJECT` SHA-256 + verification; multipart ETag or composite checksum is never treated as the logical full digest; +- incomplete, duplicate-conflicting, stale, or over-budget ledgers fail before provider complete; +- provider upload ID and part ETag/checksum never cross the application contract. + +- [ ] **Step 2: Write failing race/response-loss tests** + +Cover: + +- initiate accepted followed by lost response and ambiguous orphan discovery; +- grant response loss/reissue; +- part response loss and acknowledgement replay; +- acknowledge versus complete; +- late part request versus complete/abort; +- complete response loss and exact HEAD/ListParts resolution; +- `409` complete semantics; +- abort response loss and orphan discovery; +- process restart with active sessions; +- expiry plus maximum in-flight horizon before cleanup eligibility. +- retirement/abort racing the admission-close and `COMPLETE_IN_PROGRESS` CAS. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*DirectMultipartCoordinatorTest' \ + --tests '*DirectMultipartRaceTest' \ + --tests '*S3DirectMultipartProviderTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because direct multipart coordination does not exist. + +- [ ] **Step 4: Implement the minimum direct multipart path** + +Reuse the sharded control ledger, but keep managed and direct states distinct. Presign one exact +part per grant. Never accept client-supplied ETag/upload ID directly at completion. Abort requires +session state/fence and produces a pending effect; a timeout remains indeterminate until resolved. +For an R2-capable primitive, obtain the full logical SHA-256 by a bounded exact-version verification +read when provider evidence is only composite; do not publish before that read passes. Register the +direct multipart delegate and every new durable state/field explicitly in the selected S3 +contribution and closed codec with golden compatibility fixtures. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 19: Qualify direct security/fault behavior without exposing an endpoint + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java` +- Modify: + `docs/registries/object-storage-readiness.yaml` + +- [ ] **Step 1: Write failing real-provider tests** + +For the exact provider/version test: + +- method, host, path, signed headers, checksum, expiry, create-only, and content constraints; +- clock-skew boundary, unhealthy-clock admission failure, and expiry below both credential horizon + and the exact attested signature-age ceiling; +- browser-visible CORS request headers and exposed completion headers; +- URL expiration and the explicit limitation that revocation is not immediate; +- direct completion verification; +- direct multipart acknowledgement/ListParts/complete/abort; +- direct multipart grant/ack close, late-request horizon, conditional complete, and exact-version + full-object digest verification (never ETag/composite substitution); +- response loss, Toxiproxy cut, concurrent replay, process restart, and orphan eligibility; +- log/trace/metric capture with zero URL/query/provider locator leakage. + +- [ ] **Step 2: Verify RED in the required MinIO lanes** + +Run: + +```bash +cd src +./gradlew \ + :adapter:outbound:objectstorage:objectStorageMinioContractTest \ + :adapter:outbound:objectstorage:objectStorageMinioFaultTest \ + --tests '*DirectTransfer*' --console=plain +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*DirectTransferCorsContractTest' --console=plain +``` + +Expected: failures until every advertised direct profile and the normal-source-set browser CORS +contract pass. Docker absence remains a failure. + +- [ ] **Step 3: Implement only missing qualified direct semantics** + +Fix provider mapping, session reconciliation, CORS evidence, and redaction needed by the advertised +profiles. Do not weaken hard-size/create-only/checksum/expiry requirements. Mark a behavior +`UNSUPPORTED` when the exact topology cannot prove it. + +- [ ] **Step 4: Re-run the required MinIO lanes GREEN** + +Run both commands from Step 2. Expected: PASS for every advertised direct profile with zero +selected skips and for the normal-source-set CORS contract; unsupported profiles have explicit +negative descriptor tests. + +- [ ] **Step 5: Compile, but do not execute, the AWS direct qualification lane** + +Approval Gate B has not yet granted external mutation authority. Compile only: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain +``` + +Expected: PASS with no observed AWS direct evidence. If represented in the registry, use R0 plus +`limitation: authority_pending`, not a new evidence status. Task 29 executes it after Gate B. No +AWS/direct R2 claim is possible in this plan because no public direct API is implemented. + +- [ ] **Step 6: Publish only truthful Batch D state** + +The registry may record R1 for exact functional profiles. Any hard-size, create-only, checksum, +CORS, or late-request behavior that is not proven remains `UNSUPPORTED`/`UNVERIFIABLE`. No row or +documentation implies that a public endpoint exists, and all direct cards remain at most R1/partial. + +- [ ] **Step 7: Verify rollback** + +Disable new grant admission, retain the session resolver, let issued grants expire through their +in-flight horizon, and reconcile/abort without deleting session records early. Managed upload and +published download remain available. + +- [ ] **Step 8: Run Batch D checkpoint** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:check --console=plain +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +bash ../.github/scripts/verify-gate-matrix.sh +``` + +Expected: PASS after the required MinIO direct lanes have passed with zero selected skips. Re-run +`ObjectStorageReadinessRegistryTest` as part of `check`; record both AWS managed/direct lanes as +authority-pending, not as passed evidence. Perform the mandatory Batch D status/Wiki capture before +Approval Gate A. + +--- + +## Approval Gate A — Scanner ownership and sample public contract + +Do not execute Tasks 20–24 until the approved design records all of the following: + +- staged scan fencing may be implemented with a test fake, but no production scanner/provider is + claimed; +- the sample's first migrated profile is either `integrity-verified-reference` or an explicitly + named scan-gated profile with a real scanner owner; +- the additive endpoint proposal + `POST /posters/{id}/image-publications`, status, authorization, request size/media policy, and + locator-free response fields; +- the asynchronous response contract: POST always returns the same bounded `202` reservation + (opaque publication operation plus status link) replayed by `IdempotencyExecutor`; a separately + authorized GET status resource reports progress and only exposes the published reference at + READY. The POST never sometimes returns a final payload under the same idempotency record; +- whether exact file SHA-256 is supplied as a bounded multipart field or a newly registered HTTP + header, including its canonical encoding; it is required before TX1, validated again while + staging, and multipart boundary/order is never part of the semantic fingerprint; +- required `Idempotency-Key`, fingerprint scope, and the rule that the first committed + `UploadIntent` allocates/reuses the stable `ObjectOperationId`; +- the exact `IdempotencyExecutor` shape: inside one TX1 it atomically claims/completes the stable + `202` reservation and creates-or-reads the durable intent keyed by versioned HMAC scope digest; + the same HTTP invocation then consumes the request-bound producer outside TX through stage/verify + and TX2 PENDING before returning that reservation; finalize/READY is asynchronous operation-keyed + continuation, and generic replay never allocates a second operation; +- the current sample remains permission-based (`poster:write`) because Poster has no owner + attribute; do not invent per-resource ownership checks without a separate domain/schema design; +- the explicit profile/release gate for the legacy `/posters/{id}/image` endpoint; +- a consumer inventory and owner approval for the existing `poster.image-attached` broker event; + choose a new versioned event type/envelope (preferred) or an explicit bounded dual-publish + window, consumer migration evidence, rollback, and zero-consumer proof before v1 removal. Never + rename `imageKey` to `reference` under the same unversioned event contract; +- intentional OpenAPI snapshot approval; +- the additive V7 schema and forward-only rollback window. +- the compatibility model during V7: existing legacy attachments remain readable/publishable, + legacy writes are restricted to the compatibility controller/profile, and the canonical profile + writes only READY opaque references. + +If these decisions change the deep design, amend and re-review the design first. Planning this gate +does not constitute API approval. + +## Batch E — Phase 5: Staged publication and sample migration + +### Task 20: Implement staged integrity/scan/publication and application handoff fencing + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/`: + `StagedObjectPublicationKernel.java`, `ObjectIntegrityVerificationService.java`, + `ObjectScanVerdictPolicy.java`, `ObjectReferencePublicationService.java`, + `ObjectPublicationHandoffService.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectMutationAdapter.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectScanMaintenanceAdapter.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernelTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectScanVerdictFenceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectPublicationHandoffRaceTest.java` + +- [ ] **Step 1: Write failing staged-state tests** + +Prove: + +- stage returns only an `ObjectStageHandle`; +- public inspect/read/download grant reject staged/quarantined objects; +- integrity mismatch never becomes verified or published; +- a scan-required destination cannot finalize before `CLEAN`; +- `MALICIOUS` and `INDETERMINATE` fail closed; +- verdict binds exact stage, object version, scan operation, scanner policy/version, and record + revision; +- duplicate same verdict replays and stale/different verdict conflicts; +- finalize is the first operation to create an opaque published reference. +- finalize first appends an immutable manifest revision binding `ObjectId` and exact immutable + provider version/evidence, then conditionally creates/CASes the small reference-current pointer; + response loss resolves both exact records and never rebuilds from current binding defaults. + +- [ ] **Step 2: Write failing handoff/abort race tests** + +Cover: + +- claim, renew, release with monotonically increasing fence; +- stale worker cannot mark PENDING/READY or release a newer claim; +- active claim blocks abort; +- claim expiry alone does not authorize destructive abort; +- application intent first CASes `ABORT_AUTHORIZED` and then issues an exact + `ObjectAbortAuthorization`; +- late finalize versus abort authorization; +- missing application intent never triggers auto-delete; +- object without matching intent is quarantined/reported. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*StagedObjectPublicationKernelTest' \ + --tests '*ObjectScanVerdictFenceTest' \ + --tests '*ObjectPublicationHandoffRaceTest' \ + --tests '*ObjectControlRecordCodecTest' \ + --tests '*ObjectStorageCapabilityConfigTest' \ + --tests '*ObjectStorageScanMaintenanceConfigTest' --console=plain +``` + +Expected: compilation failure because staged/handoff implementations do not exist. + +- [ ] **Step 4: Implement the minimum staged kernel** + +Use the existing semantic ports from Task 4. Integrity verification may stream the unpublished +exact version through the narrow maintenance/scan read path. Scanner policy remains an input +verdict seam, not an objectstorage-owned malware engine. The handoff service never imports +persistence/sample types and accepts only application-provided claim/authorization contracts. +Register the handoff durable family in the closed codec with golden/old-reader fixtures and expose +publication only through the selected normal mutation facade. Scan exact-version read remains a +separate `ObjectScanMaintenancePort` facade/config with explicit scanner-workflow activation; no +scan or privileged purge port is registered in the normal context. Extend the +disabled/unselected/selected `ApplicationContextRunner` matrix and prove the normal facade cannot be +cast or injected as either privileged type. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. This is R1 protocol evidence only; without a production +scanner, `object-storage-quarantine-publication` remains below R2. + +### Task 21: Add the forward-only Poster image intent and dual-read schema + +**Files:** + +- Modify: + `src/sample-portfolio/build.gradle` +- Modify: + `src/sample-portfolio/gradle.lockfile` +- Create: + `src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster_image_publication.sql` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`: + `PosterImageUploadIntent.java`, `PosterImageUploadIntentState.java`, + `PosterImageUploadIntentStorePort.java`, `PosterImageUploadIntentClaim.java`, + `PosterImageUploadIntentConflictException.java`, `PosterImageRetirementIntent.java`, + `PosterImageRetirementIntentState.java`, `PosterImageRetirementIntentStorePort.java`, + `PosterImageIdempotencyScopeDigest.java`, `PosterImageIdempotencyScopeDigesterPort.java`, + `PosterImageIdempotencyKeyEpochPort.java`, + `PosterImageSanitizedIdempotencyContextFactory.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/`: + `PosterImageAttachment.java`, `LegacyPosterImageAttachment.java`, + `PublishedPosterImageAttachment.java`, `PosterImageReference.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/`: + `PosterImageUploadIntentEntity.java`, `PosterImageRetirementIntentEntity.java`, + `PosterImageIdempotencyKeyEpochEntity.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/`: + `PosterImageUploadIntentJpaRepository.java`, + `PosterImageUploadIntentRepositoryAdapter.java`, + `PosterImageRetirementIntentJpaRepository.java`, + `PosterImageRetirementIntentRepositoryAdapter.java`, + `PosterImageIdempotencyKeyEpochJpaRepository.java`, + `PosterImageIdempotencyKeyEpochRepositoryAdapter.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageIdempotencyKeyRingSettings.java`, `PosterImageIdempotencyConfig.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java` +- Create: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV7MigrationQualificationTest.java` +- Create: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` +- Modify: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` + +- [ ] **Step 1: Register the non-skipping migration lane and locks** + +Register `posterImageMigrationTest` with Docker/PostgreSQL prerequisites that fail when absent, add +it to the container CI workflow/gate matrix, then regenerate/review the lock before using the lane +as RED: + +```bash +cd src +./gradlew :sample-portfolio:resolveAndLockAll --write-locks +./gradlew :sample-portfolio:verifyDependencyLocks --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +``` + +Expected: the task/configuration exists and locks are valid; no migration behavior is claimed yet. + +- [ ] **Step 2: Write failing migration/repository tests** + +Run real PostgreSQL/Flyway and prove: + +- fresh V1–V7 and V6→V7 upgrade; +- V6 is unchanged; +- existing `poster.image_key` rows remain readable through the internal dual-read mapper; +- new READY attachments store only bounded opaque `image_reference`; +- legacy rows map to a discriminated legacy attachment and remain readable/publishable in the + approved compatibility window; canonical writes attach only READY published references; +- pending state lives in the intent, not as an attached domain image; +- active operation/idempotency uniqueness; +- a bounded, non-reversible idempotency-scope digest (not the raw header/principal) maps retries to + the committed operation, and the stored request fingerprint detects scope reuse with new intent; +- state/revision/fence CAS permits one winner; +- `SUPERSEDED` is a durable terminal intent state and requires a same-transaction exact + losing-reference retirement row when publication already occurred; +- intent survives Poster deletion long enough to reconcile/retire; +- no object payload or presigned URL column exists; +- replacement/delete work uses a separate durable `poster_image_retirement_intent` row and cannot + be inferred from a deleted Poster or overloaded upload intent. + +The V7 intent table must include stable operation identity/epoch/destination, request fingerprint, +bounded idempotency-scope digest, expected poster version, exact content identity/media type, state, +stage handle, handoff claim/fence/expiry, published reference/version, replaced reference, +retry/error timestamps, and optimistic revision. Index the unique scope, operation, worker claims, +and expiry. Avoid a cascading FK that deletes required cleanup evidence. + +Store idempotency lookup as `(hmac_key_version, digest)`, never raw scope or plain SHA-256. The +HMAC input is a frozen `poster-image-idempotency-scope-v1` domain separator followed by +length-prefixed UTF-8 fields for tenant presence/value, principal, use-case, and Idempotency-Key; +ambiguous concatenations and tenant A/B must produce different golden vectors. Load an operator-supplied, +permission-checked versioned key-ring file from +the typed `app.poster-image.idempotency.key-ring-path` setting; do not add it to root `src/.env` or +an app-bootstrap-only env registry. The canonical production profile requires a nonempty active +key. Retain old keys for at least the maximum intent/idempotency/reconciliation horizon and rotate +overlap-first. A DB-coordinated key-epoch row is locked in the same reservation transaction: all +pods must possess the DB-active key; a stale pod fails new admission, and activation changes only +after every pod has the new retained key. Reservation queries all retained-version digests before +inserting under the active version. Tests race old/new pods across activation and prove one intent, +same-scope lookup, and no key material disclosure. Do not reuse the logging/privacy salt. + +Before invoking the generic executor, derive a storage-safe `IdempotencyContext`: the optional +tenant and principal dimensions become separately domain-separated HMAC aliases, the +`idempotencyKey` dimension becomes the full versioned scope digest, and `useCaseName` is the fixed +publication-reservation identifier. The raw client key, tenant, and principal remain request-memory +inputs only. Tenant presence/value remains part of every alias/digest, so tenant isolation is not +collapsed. Golden and ambiguous-tuple tests cover both the intent key and sanitized generic scope. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :sample-portfolio:test \ + --tests '*PosterImageReferenceTest' \ + --tests '*PosterTest' \ + --tests '*HmacPosterImageIdempotencyScopeDigesterTest' \ + --tests '*PosterImageIdempotencyConfigTest' \ + --tests '*PosterImageUploadIntentRepositoryIntegrationTest' \ + --tests '*PosterRepositoryAdapterIntegrationTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +``` + +Expected: compilation/migration failures because V7, the intent model, key-ring binding, and +non-skipping qualification lane do not exist. Docker/PostgreSQL absence is an actionable failure, +not a skip or passing default test. + +- [ ] **Step 4: Implement additive persistence only** + +Do not call object storage from Flyway, an entity callback, repository mapper, or transaction +listener. Keep `image_key` for dual read; add `image_reference`, upload intent, and retirement +intent additively. During the approved window, the domain represents legacy versus published +attachment explicitly and permits existing legacy attachments; the canonical writer accepts only +a READY opaque reference. Keep the existing `imageKey()` accessor, legacy command, and +`PosterImageAttached` event source-compatible through this task so all current main sources compile. + +- [ ] **Step 5: Verify GREEN** + +Run the exact Step 3 commands again, then verify locks: + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*IdempotencyStoreAdapterTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*PosterImageReferenceTest' \ + --tests '*PosterTest' \ + --tests '*HmacPosterImageIdempotencyScopeDigesterTest' \ + --tests '*PosterImageIdempotencyConfigTest' \ + --tests '*PosterImageUploadIntentRepositoryIntegrationTest' \ + --tests '*PosterRepositoryAdapterIntegrationTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +./gradlew :sample-portfolio:resolveAndLockAll --write-locks +./gradlew :sample-portfolio:verifyDependencyLocks --console=plain +``` + +Expected: PASS, and the `posterImageMigrationTest` result XML reports zero skipped tests. + +- [ ] **Step 6: Verify rollback window** + +The non-skipping `PosterImageV7MigrationQualificationTest` must run a V6-compatible SQL/JPA +projection against the expanded schema, including old INSERT/UPDATE/read behavior. Document that +rollback is binary-only while old code ignores additive columns; there is no down migration, +column drop, intent deletion, or object mutation. + +### Task 22: Move Poster publication through short transactions and crash-safe handoff + +**Files:** + +- Create: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyClaimRepository.java` +- Create: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepository.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java` +- Test: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlIdempotencyClaimRepositoryTest.java` +- Modify: + `src/adapter/outbound/persistence-jpa/README.md` +- Modify: + `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationResult.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintFactory.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintCodec.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageUploadReconciliationJob.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageUploadReconciliationSettings.java`, `PosterImageObjectStorageConfig.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/`: + `PosterImageAttachmentPrepared.java`, `PosterImageAttachmentReadyV2.java`, + `PosterImagePublicationEventPublisher.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` +- Create: + `docs/evidence/object-storage/poster-image-event-consumers.md` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicyTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprintTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventContractTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationCrashMatrixTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationConcurrencyTest.java` +- Test: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageReservationAtomicityQualificationTest.java` + +- [ ] **Step 1: Write failing transaction-boundary tests** + +Use a tracking `TransactionPort` and fake object ports. Fail the test if producer invocation, +stage/inspect/finalize/abort/resolve, or any external I/O occurs while a transaction is active. +Prove the order: + +```text +TX1 reserve UploadIntent RESERVED and commit +outside TX stage/verify (and scan when configured) +object handoff claim +TX2 CAS intent RESERVED -> PENDING with matching fence and commit + append versioned AttachmentPrepared notification in the same TX +outside TX finalize publication +TX3 CAS poster expected version + intent PENDING -> READY, + insert exact replacement retirement intent and versioned AttachmentReady notification, commit + OR, when Poster CAS loses, transition this intent -> SUPERSEDED and insert an exact retirement + intent for this operation's already-published losing reference in the same transaction +release handoff claim +claim/reconcile the independent retirement intent outside TX +``` + +The committed `poster_image_upload_intent` state is the canonical work queue. A bounded polling +worker claims `PENDING` rows and renews the object handoff; the transactional outbox events are +versioned integration notifications, not the only wake-up or an object-operation journal. This +choice must be mirrored in the deep design before implementation. + +- [ ] **Step 2: Write the failing crash-gap matrix** + +Inject a process/application stop: + +- after intent commit before stage; +- after data mutation before stage receipt; +- after stage before claim; +- after claim before DB PENDING; +- after DB PENDING before finalize; +- after finalize response loss; +- after publish before DB READY; +- after DB READY before claim release; +- before and after the single TX1 commit that atomically covers generic idempotency claim, + UploadIntent create-or-read, and generic reservation completion. + +Retry/reconciler must reuse the stable operation, avoid producer replay after staged evidence exists, +attach only exact READY reference, and never delete merely because a row is absent. The +non-skipping PostgreSQL atomicity test must prove a kill/failure before commit leaves neither row, +while commit leaves both COMPLETED generic reservation and matching intent—never a durable generic +`IN_FLIGHT` row without an intent. It also races two same-scope transactions and proves the loser +replays/commits normally, and proves expired reclaim. + +A worker must not attempt to recreate request bytes for a `RESERVED` intent. If the process dies +after TX1 but before staging, a same-key/same-fingerprint HTTP retry supplies a fresh producer and +continues the same operation; without retry, bounded intent expiry may authorize abort/report, not +invent data or publish. Once exact staged evidence exists, retry skips producer invocation. + +- [ ] **Step 3: Write failing concurrency tests** + +Cover same idempotency key/same fingerprint, same key/different fingerprint, two replacement +operations against one expected Poster version, reserve-expiry versus late PENDING, abort +authorization versus finalize, and Poster deletion during replacement. Prove deterministic CAS +winners. If finalize already published before the Poster CAS loses, commit `SUPERSEDED` plus a +dedicated exact losing-reference/version retirement row in the same transaction; a thrown/rolled +back CAS path is forbidden. Prove that row survives deletion, worker takeover, response loss, and +process restart. Prove the versioned +ready event exposes an opaque reference rather than a raw key. The old +`poster.image-attached`/`imageKey` contract is unchanged; the Gate A choice controls a new event +type/envelope or bounded dual publish, with checked-in consumer inventory and rollback evidence. + +Freeze `poster-image-publication-fingerprint-v1` as length-prefixed canonical bytes over schema, +Poster ID, expected Poster version, destination/profile, normalized media type, exact declared +length, and caller-supplied full-file SHA-256. Exclude multipart boundary, part/header ordering, +filename, and transport framing. Golden vectors prove two encodings of the same multipart semantics +match, while any semantic field change conflicts. The factory runs before TX1 without consuming the +file stream; staging recomputes byte count/SHA-256 and rejects a mismatch. + +- [ ] **Step 4: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*IdempotencyStoreAdapterTest' \ + --tests '*PostgreSqlIdempotencyClaimRepositoryTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*PosterImagePublicationPolicyTest' \ + --tests '*PosterImagePublicationFingerprintTest' \ + --tests '*PosterImagePublicationEventContractTest' \ + --tests '*PosterImageObjectStorageConfigTest' \ + --tests '*PosterImagePublicationWorkflowTest' \ + --tests '*PosterImagePublicationCrashMatrixTest' \ + --tests '*PosterImagePublicationConcurrencyTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest \ + --tests '*PosterImageReservationAtomicityQualificationTest' --console=plain +``` + +Expected: new recoverable reservation/publication symbols are absent. The existing legacy +`UploadPosterImageCommand`, `UploadPosterImageUseCase`, controller, response, and wire test remain +unchanged and must still compile/pass in this task. + +- [ ] **Step 5: Implement the minimum workflow** + +The new `PublishPosterImageCommand` may carry the framework-free `ObjectContentProducer`, exact +content identity, media type, `CallBudget`, cancellation, and `IdempotencyContext`; it must not carry +`MultipartFile` or another inbound type. Keep the legacy command/use case intact until Task 23. +`PublishPosterImageUseCase` is `Idempotency.KEYED`, but the generic executor wraps only TX1 +create-or-read reservation and immediately stores the bounded stable operation/intent result. +Build `RequestFingerprint` only from the canonical semantic fingerprint above, never raw multipart +bytes/boundary. +The exact shape is +`tx.inWrite(() -> idempotencyExecutor.execute(sanitizedContext, createOrReadIntent, +reservationCodec))`; +the existing JPA idempotency store participates in that caller transaction, and the reservation is +small enough to stay inline. Thus generic claim, durable intent, and generic COMPLETED response +commit or roll back together. The POST-facing use case always returns that same `202` reservation; +it never mixes a later READY payload into the generic replay record. In that same HTTP invocation, +after TX1 commits, consume the request-bound producer to stage/verify outside TX, acquire handoff, +and commit TX2 PENDING; only then return the stable reservation. Finalize/TX3 READY is keyed by the +operation and owned by the bounded intent worker. The authorized status query reads the intent and +returns a locator-free progress/result view. No remote I/O or producer invocation occurs in TX1, +and no worker reads a request producer after the response. Allocate the operation ID only in the +committed intent and reuse it after generic replay/expiry. Application policy owns Poster +media/size, permission, and idempotency semantics; transport and destination policies do not, and +this sample does not invent resource ownership. + +Before relying on the outer transaction, add a vendor-neutral `IdempotencyClaimRepository` SPI and +implement this exact statement only in the allowed `.postgresql` package: +`INSERT ... ON CONFLICT ON CONSTRAINT uq_idempotency_scope DO UPDATE SET +id=EXCLUDED.id, request_hash=EXCLUDED.request_hash, status='IN_FLIGHT', +response_payload=NULL, response_ref=NULL, created_at=EXCLUDED.created_at, +expires_at=EXCLUDED.expires_at WHERE idempotency_record.expires_at <= :now RETURNING id`. +One returned ID means a new/expired claim won; no row means a live winner exists. A uniqueness +exception must never poison the caller transaction. + +Because executor `find` can load an expired entity before native reclaim, detach only that exact +expired `IdempotencyRecordEntity` before the claim and reload the returned ID/fingerprint before +complete; never call `EntityManager.clear()` or detach unrelated business entities. The +sample/PostgreSQL configs explicitly select this implementation; no vendor SQL enters the generic +idempotency package. Preserve the existing application port contract and prove winner/loser, +expired-find→reclaim→complete with new fingerprint/expiry/COMPLETED state, and unrelated managed +entity preservation in non-skipping real PostgreSQL. Do not emulate with a process lock. Update the +owner README/CLAUDE vendor SPI table. The same qualification queries both `idempotency_record` and +V7 intent tables and proves neither contains the raw Idempotency-Key, tenant, or principal; stored +aliases/digests retain tenant separation. + +Reconciliation settings are constructor-bound, disabled by default, and bound batch size, +claim/renew duration, fixed delay, retry/backoff, concurrency, and shutdown grace. The disabled +context creates no scheduler/thread and performs no DB/object access. The explicit canonical sample +profile enables the new capability; the legacy-only profile remains unchanged until Task 23. + +- [ ] **Step 6: Verify GREEN** + +Run the command from Step 4 plus: + +```bash +cd src +./gradlew :sample-portfolio:test \ + --tests '*PosterControllerWireTest' \ + --tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain +``` + +Expected: PASS with both the new semantic application slice and unchanged legacy slice compiling; +no endpoint switches in this task. + +### Task 23: Add bounded multipart ingress and an approved locator-free response + +**Files:** + +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/`: + `PosterImagePublicationResponse.java`, `PosterImagePublicationStatusResponse.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/`: + `PosterImagePublicationController.java`, `LegacyPosterImageController.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageApiSettings.java`, `PosterImageApiConfig.java` +- Move: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java` + to + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/legacy/LegacyUploadPosterImageCommand.java` +- Move: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java` + to + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/legacy/LegacyUploadPosterImageUseCase.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Modify: + `src/sample-portfolio/src/main/resources/application.yml` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageControllerWireTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationStatusAuthorizationTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/architecture/PosterImageIngressArchitectureTest.java` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java` +- Modify: + `src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json` +- Create: + `src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-publication-snapshot.json` +- Modify: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java` +- Modify, only if Approval Gate A selects a new header: + `docs/registries/headers.yaml` + +- [ ] **Step 1: Write failing bounded-ingress tests** + +Prove: + +- no `MultipartFile.getBytes()` or full file materialization; +- inbound adapter opens/closes the multipart stream inside the producer callback; +- request-bound producer is fully consumed before POST returns; any attempted use after response is + rejected, and a background worker never receives a `MultipartFile`/request stream; +- chunks do not exceed the application sink limit; +- declared/exact size and SHA-256 representation are validated; +- client disconnect/read failure/cancellation is not upload success; +- controller/producer signatures leak no `MultipartFile` into sample application/domain; +- transport multipart/body hard limit and header syntax stay inbound; Poster allowed media/logical + size/permission/idempotency stay in `PosterImagePublicationPolicy`/use case; destination + max/checksum/encryption stay in the compiled object-storage binding. Architecture tests reject + those business rules in controller/mapper/producer/configuration. + +- [ ] **Step 2: Write the failing wire/API contract** + +For the exact approved endpoint, prove: + +- required authorization and `Idempotency-Key`; +- accepted digest input and canonical mismatch error; +- POST returns `202` only after exact stage evidence and TX2 PENDING are durable, with the stable + opaque publication operation and status link replayed for the same key/fingerprint; +- validation/read/stage/TX2 failure returns the normalized 4xx/5xx and does not consume the + request stream after response; a same-key/same-body retry reuses the reservation/operation and + supplies the producer again until staged evidence exists; +- authorized GET status returns progress and, only at READY, opaque reference, size, media type, and + logical digest; operation tokens are unguessable but not authorization, missing operation is 404, + and missing `poster:write` is rejected before lookup; +- the new publication/status DTOs contain no raw key, location, bucket, path, `file://`, `s3://`, + presigned URL, provider version, or internal handle; +- legacy endpoint/response exists only under the explicit compatibility profile; +- legacy and canonical image controllers are distinct conditional beans and never active + simultaneously; disabled mode exposes neither; +- signing/direct endpoints are absent. + +The pre-existing general `PosterResponse.imageKey` and legacy `StoredObjectResponse` remain +unchanged during the approved REST compatibility window. Therefore this task claims only the new +publication surface is locator-free; removing/versioning the old field requires consumer inventory, +a separate API version/rollback approval, and new snapshot. Do not silently edit both DTO shapes. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :sample-portfolio:test \ + --tests '*MultipartObjectContentProducerTest' \ + --tests '*PosterImagePublicationControllerWireTest' \ + --tests '*LegacyPosterImageControllerWireTest' \ + --tests '*PosterImagePublicationStatusAuthorizationTest' \ + --tests '*PosterImageApiConfigTest' \ + --tests '*PosterImageIngressArchitectureTest' \ + --tests '*LegacyPosterImageUploadCharacterizationTest' \ + --tests '*PosterControllerWireTest' \ + --tests '*OpenApiDriftContractTest' --console=plain +``` + +Expected: new producer/controllers/settings/status contracts do not exist. The existing controller +still materializes bytes and the approved dual-profile snapshots are absent. + +- [ ] **Step 4: Implement the approved additive API** + +Keep each controller thin. Use the framework-free producer bridge and explicit conditional config; +never place both image mappings in one controller or use component scanning as a profile switch. +Remove stereotype auto-registration from the moved legacy use case/controller and construct them +only through the legacy condition. The legacy command/use case/controller live only in the named +allowlisted `..legacy..` slice, and update the frozen ArchUnit exception to that exact package. No +other sample application package imports `application.storage`. Do not change legacy DTO/general +Poster response shape in place. When Approval Gate A authorizes the new surface, +regenerate the snapshot intentionally: + +```bash +cd src +./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange --console=plain +``` + +Review the diff for only approved changes. The approval flag is not blanket authorization for +unrelated OpenAPI drift. + +- [ ] **Step 5: Verify GREEN** + +Run the exact Step 3 command plus: + +```bash +cd src +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: PASS in disabled, legacy-only, and publication-only profile matrices. The only old-port +import is the exact legacy slice; canonical controller/status/application paths have zero legacy +imports, and direct endpoints remain absent. + +### Task 24: Add report-first legacy adoption, replacement retirement, and removal gates + +**Files:** + +- Create under + `src/application-core/src/main/java/dev/caskeleton/application/storage/migration/`: + `LegacyObjectAdoptionPort.java`, `LegacyObjectAdoptionRequest.java`, + `LegacyObjectAdoptionReceipt.java`, `LegacyObjectLocator.java`, + `LegacyObjectAdoptionApproval.java`, `LegacyObjectAdoptionApprovalVerifierPort.java` +- Modify: + `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`: + `LegacyAdoptionApprovalDocument.java`, `LegacyAdoptionApprovalCodec.java`, + `Ed25519LegacyAdoptionApprovalVerifier.java`, `LegacyAdoptionApprovalReplayRecord.java`, + `LegacyAdoptionApprovalReplayStore.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java` +- Create: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/`: + `AdoptLegacyPosterImageCommand.java`, `RetirePosterImageCommand.java`, + `ReconcilePosterImageRetirementCommand.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/`: + `LegacyPosterImageAdoptionResult.java`, `PosterImageRetirementResult.java`, + `LegacyPosterImageAdoptionAuthorizationPolicy.java`, + `LegacyPosterImageAdoptionExecutionIdentity.java`, + `LegacyPosterImageAdoptionExecutionIdentityPort.java` +- Create under + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/`: + `PosterImageRetirementJob.java`, `PosterImageRetirementSettings.java`, + `PosterImageRetirementConfig.java`, + `LegacyPosterImageAdoptionSettings.java`, `LegacyPosterImageAdoptionConfig.java`, + `LegacyPosterImageAdoptionMaintenanceRunner.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java` +- Modify: + `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageLegacyMigrationIntegrationTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/LegacyPosterImageAdoptionConfigTest.java` +- Test: + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageAdoptionAuthorizationTest.java` +- Test: + `src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java` +- Create: + `docs/evidence/object-storage/poster-legacy-migration.md` + +- [ ] **Step 1: Write failing report/adoption tests** + +Prove report-first ordering: + +```text +inventory raw key +-> exact HEAD/read digest/media/size/version +-> create immutable manifest/reference with a stable adoption operation +-> DB row compare-and-swap to opaque reference +-> retain dual read +-> separately authorize old-object retirement +``` + +Missing/corrupt/retained/unknown-version legacy objects are reported/quarantined, never overwritten, +renamed, copied, or deleted automatically. A DB CAS loser leaves evidence for reconciliation and +does not publish itself as current. Re-running returns the same reference/receipt. + +- [ ] **Step 2: Write failing replacement/delete retirement tests** + +Poster replacement and deletion must create retirement work with exact reference/version and +retention/handoff fence. Business delete is logical retirement, not privileged purge. A failure to +retire cannot resurrect the Poster or silently discard cleanup work. Prove same-transaction enqueue +for TX3 winner's replaced reference, TX3 loser's already-published `SUPERSEDED` reference, and +business delete; independent claim/lease/fence/retry after the Poster row is gone; two-worker CAS; +process death during retirement; lost retirement response resolved by exact reference/version +inspect; and durable terminal/held evidence. The worker never receives +`ObjectPurgeMaintenancePort`. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :application-core:test \ + --tests '*ObjectStorageArchitectureContractTest' --console=plain +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*LegacyObjectAdoptionServiceTest' \ + --tests '*ObjectStorageLegacyMigrationConfigTest' \ + --tests '*LegacyAdoptionApprovalVerifierTest' \ + --tests '*ObjectControlRecordCodecTest' --console=plain +./gradlew :sample-portfolio:test \ + --tests '*PosterImageLegacyMigrationIntegrationTest' \ + --tests '*DeletePosterImageRetirementTest' \ + --tests '*PosterImageRetirementCrashMatrixTest' \ + --tests '*PosterImageRetirementConfigTest' \ + --tests '*LegacyPosterImageAdoptionConfigTest' \ + --tests '*LegacyPosterImageAdoptionAuthorizationTest' --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest \ + --tests '*PosterImageRetirementQualificationTest' --console=plain +``` + +Expected: failures because adoption and retirement workflows do not exist. + +- [ ] **Step 4: Implement report-only, then reviewed apply** + +The first runnable mode emits a bounded report and performs no mutation. Apply mode requires an +explicit reviewed manifest of candidates and per-row CAS. Flyway never performs backfill. Keep old +route/binding readers and legacy objects until observation proves zero legacy reads and all +missing/corrupt cases are resolved. The application contract for this operation remains in the +deprecated legacy migration namespace and may be injected only into the named administrative +adoption use case; no normal business endpoint may use it. `LegacyObjectLocator` is capped at 1,024 +UTF-8 bytes, rejects controls, redacts `toString`/errors, never appears in adoption receipts, and is +the exact architecture-tested exception to the new semantic no-locator rule. + +The adoption use case lives in the allowlisted `..poster.migration..` package, requires the exact +administrative permission, and is assembled only in an explicit maintenance profile. Settings bind +report path, reviewed-manifest path/digest, batch size, deadline, and mode; default is disabled and +`REPORT_ONLY`. `APPLY` additionally requires a detached, canonical +`LegacyObjectAdoptionApproval` document. Its JDK-only length-prefixed codec binds the approval +schema version, adoption operation ID, exact reviewed-manifest SHA-256, legacy namespace digest, +target destination/namespace, literal mode `APPLY`, `notBefore`, expiry, nonce, and two distinct +approver key IDs. Both approvers independently sign those exact canonical bytes with Ed25519. +Trusted public keys come only from permission-checked configured files; inline or manifest-supplied +keys are rejected. The verifier checks canonical re-encoding, both signatures, distinct trusted +approvers, every binding, time window, and bounded manifest bytes before any mutation. + +After verification, a durable replay record CAS-binds the nonce to the same operation, manifest, +namespace pair, and approval digest. A terminal replay of that exact operation is idempotent; +reuse for any other operation or digest fails closed. `LegacyPosterImageAdoptionAuthorizationPolicy` +then requires the isolated maintenance execution identity, exact administrative capability, and +verified approval receipt before invoking the adoption port. The maintenance runner supplies that +identity explicitly; it neither depends on a web `SecurityContext` nor treats +`@RequiresPermission` as scheduler authorization. Call order is: + +```text +load bounded reviewed manifest and signed approval +-> verify canonical bytes, two signatures, bindings, time window, and execution identity +-> claim/replay-CAS nonce for the exact adoption operation +-> re-read and re-hash the immutable manifest +-> per-row inspect/digest and CAS adoption +-> mark exact replay record terminal with evidence +``` + +Tests fail closed for absent or malformed approval, forged signature, one signer, the same signer +twice, untrusted key, wrong operation/manifest/legacy namespace/target namespace/mode, expired or +not-yet-valid approval, nonce replay against a different binding, changed manifest bytes, missing +or mismatched maintenance identity, and normal-context invocation. `REPORT_ONLY` does not require +an APPLY approval and cannot enter the mutating path. The normal web/application context exposes +neither the raw-locator port nor an adoption entrypoint. + +Neither the adapter adoption service nor the sample adoption use case/job uses a component +stereotype. Explicit maintenance configurations assemble them only after mode/profile, reviewed +digest, signed-approval trust store, permission boundary, maintenance identity, and isolated +legacy/new namespaces validate; normal and canonical publication contexts assert zero +migration-port/verifier/replay-store/runner beans and zero side effects. The closed control codec +registers approval replay records with golden/new-reader/old-reader compatibility fixtures. + +Retirement settings are constructor-bound and disabled by default, with bounded claim/renew, +batch/page, delay, retry/backoff, concurrency, and shutdown grace. The job claims the dedicated V7 +retirement table and calls only logical exact-reference retirement outside DB transactions. A +disabled context creates no scheduler/thread; stale fence/takeover is rejected and response loss +remains operation-keyed exact-reference/version reconciliation, not blind retry. TX3 replacement and +`DeletePosterUseCase` insert the row in the same DB transaction; its FK/retention preserves work +after Poster deletion. Physical purge remains separately privileged in Tasks 26–29. + +All three named use cases implement the repository `CommandUseCase` convention with explicit +commands/results and `@UseCaseCapability`; jobs invoke those boundaries rather than calling a +repository/port directly. Adoption carries the admin-only legacy exception, while logical +retirement/reconciliation use only opaque exact references. + +- [ ] **Step 5: Run Batch E checkpoint** + +Run: + +```bash +cd src +./gradlew \ + :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + :adapter:outbound:objectstorage:check \ + :sample-portfolio:check --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot --console=plain +``` + +Expected: PASS. Record sample workflow R1 evidence only. Do not remove legacy types, columns, +objects, readers, or endpoint merely because the new happy path passes. + +--- + +## Approval Gate B — Production AWS topology and authority + +Do not execute Batch F protected/provider mutations until an authorized deployment owner provides: + +- a pre-provisioned regional AWS S3 general-purpose bucket plus isolated disposable qualification, + backup-export, and restored-destination namespaces; +- exact account/bucket owner, region, endpoint/network path, ownership controls, Block Public + Access, versioning, lifecycle, incomplete-multipart policy, encryption/KMS, retention profile, + bounded `s3:signatureAge` policy, qualified clock-skew source, and CORS decisions; +- a workload role/default-chain credential path with least-privilege data/control/qualification/ + maintenance separation and credential-refresh evidence; +- an IaC-produced, expiry-bounded, Ed25519-signed capability attestation and trusted public key; +- permission to run non-destructive safe probes and separately authorized cleanup in the + qualification namespace; +- permission to create a bounded backup of exact test data/control versions and restore it into the + disposable restored namespace for R2 reconciliation evidence; this is not authority for a + regional production DR game day; +- KMS/S3 throttle, permission revocation, process kill, network fault, and cleanup test windows; +- CI secret handling and evidence retention; +- the exact cards/destination profiles proposed for R2. +- Object-Lock-enabled topology and separate privileged retention/hold/purge role only if the + retention card is proposed; otherwise that card remains below R2. + +The runtime must not create or alter buckets, IAM, KMS, lifecycle, versioning, Object Lock, BPA, +ownership controls, or CORS. If the authority/topology is unavailable, complete the code/tests that +do not require it and leave the exact card below R2; do not substitute MinIO or mocks. + +## Batch F — Phase 6: Production security, maintenance, and exact R2 evidence + +### Task 25: Verify signed deployment attestation and fail-closed AWS startup qualification + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/`: + `ObjectStorageDeploymentAttestation.java`, `ObjectStorageAttestationCodec.java`, + `Ed25519ObjectStorageAttestationVerifier.java`, `ObjectStorageSafeProbe.java`, + `AwsS3StartupQualifier.java`, `ObjectStorageQualificationCache.java`, + `ObjectStorageQualificationFailure.java`, `ObjectStorageClockHealth.java`, + `AwsS3VersioningPropagationQualifier.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/security/`: + `ObjectStorageCredentialPolicy.java`, `ObjectStorageEndpointPolicy.java`, + `ObjectStorageEncryptionPolicy.java`, `ObjectStorageSecurityValidator.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectStorageProviderContribution.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderCompositionTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageAttestationVerifierTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3StartupQualifierTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/security/ObjectStorageSecurityConfigurationTest.java` + +- [ ] **Step 1: Write failing attestation tests** + +Use deterministic Ed25519 fixtures and reject: + +- invalid signature, non-canonical document, unknown/newer schema; +- expired/not-yet-valid evidence or expiry beyond configured maximum; +- wrong account/owner/bucket/region/provider/deployment identity; +- binding/policy/encryption/versioning/lifecycle/ownership/BPA/profile digest mismatch; +- missing/mismatched `versioningActivatedAt`, approved propagation-soak duration, + maximum `s3:signatureAge`, time-source identity, or qualified maximum clock skew; +- an attestation that advertises a card combination not explicitly qualified; +- key/document path traversal, symlink, world-writable file, oversize, or value leakage; +- last-known-good use without a signed grace policy. + +- [ ] **Step 2: Write failing startup/security tests** + +Prove: + +- required destination fails startup on missing/mismatched evidence; +- minimal probe uses expected owner and only the reserved qualification namespace; +- probe does bounded create/HEAD/GET/conditional-CAS/delete only when authorized; +- no provisioning/config mutation API is called; +- production requires HTTPS, approved public presign host, default-chain temporary credentials, + expected owner, private ownership/BPA, and approved encryption profile; +- new grant admission requires healthy time synchronization and an expiry strictly inside both + credential horizon minus qualified skew and the attested signature-age ceiling; +- after the attested versioning activation time plus approved soak, a bounded sentinel + create/read/new-version/exact-version-delete/delete-marker check passes before mutation admission; + a recent/unknown activation or failed sentinel keeps the destination unqualified; +- static access/secret literals, anonymous credentials, public ACL, governance bypass, plaintext + endpoint, and auto-create fail; +- disabled/unselected provider performs no attestation read, credential resolution, client + creation, DNS, or probe. + +- [ ] **Step 3: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageAttestationVerifierTest' \ + --tests '*AwsS3StartupQualifierTest' \ + --tests '*ObjectStorageSecurityConfigurationTest' \ + --tests '*S3ProviderCompositionTest' --console=plain +``` + +Expected: compilation failure because attestation/production qualification does not exist. + +- [ ] **Step 4: Implement bounded verification and qualification** + +Use JDK Ed25519 and strict canonical JSON. Cache exact evidence with expiry and refresh before its +horizon. Refresh failure never recompiles an existing operation against current settings. After +expiry, block new required mutations/grants; published exact-version reads continue only under an +explicit signed read-continuity policy plus live safe probe. Emit redacted audit/metrics later in +Task 28. Versioning propagation is evidence, not a sleep inside startup: compare the signed +activation instant to the approved soak and run the sentinel only after the horizon. Wire +qualification only through the selected S3 contribution; disabled/unselected contexts remain +side-effect free and the normal context still has no privileged purge bean. + +- [ ] **Step 5: Verify GREEN** + +Run the command from Step 3. Expected: PASS. + +### Task 26: Implement version-aware retirement, retention evidence, and privileged exact purge + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`: + `VersionAwareObjectRetirementService.java`, `PrivilegedObjectPurgeService.java`, + `ObjectRetentionDecision.java`, `ObjectPurgeAuthorization.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/`: + `S3RetentionEvidenceReader.java`, `S3VersionAwarePurgeProvider.java`, + `S3PrivilegedPurgeProviderContribution.java`, `S3PrivilegedPurgeClientFactory.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeSettings.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfig.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedProviderAssembler.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStoragePrivilegedPurgeConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3PrivilegedPurgeCompositionTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/VersionAwareObjectRetirementTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/PrivilegedObjectPurgeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3RetentionEvidenceReaderTest.java` + +- [ ] **Step 1: Write failing lifecycle/privilege tests** + +Prove: + +- retirement makes a published reference unavailable through business read/grant but does not + imply physical deletion; +- exact object version, reference revision, handoff fence, retention policy revision, and purge + operation are required; +- a delete marker is not evidence that a noncurrent version was physically purged; +- active retention/legal hold yields `HELD`, not success or bypass; +- `HELD` requires exact successful `GetObjectRetention`/`GetObjectLegalHold` evidence; a generic + `403`, timeout, unavailable API, or unmapped provider error is `UNKNOWN`/fail-closed, never held; +- governance bypass is absent from the normal role/path; +- general business composition cannot obtain `ObjectPurgeMaintenancePort`; +- response loss resolves exact version state before retry; +- object/version mismatch, unknown schema, or missing authorization never deletes. +- if `object-storage-retention` is proposed at R2, versioning, exact noncurrent-version purge, + delete-marker behavior, lifecycle interaction, retention/legal-hold reads, and the split + privileged role are mandatory; otherwise the card remains below R2. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*VersionAwareObjectRetirementTest' \ + --tests '*PrivilegedObjectPurgeTest' \ + --tests '*S3RetentionEvidenceReaderTest' \ + --tests '*ObjectStoragePrivilegedPurgeConfigTest' \ + --tests '*S3PrivilegedPurgeCompositionTest' --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +``` + +Expected: compilation failure because version-aware retirement/purge does not exist. + +- [ ] **Step 3: Implement split business and maintenance paths** + +Retirement is a reference-state CAS. Purge uses a separately composed least-privilege provider and +exact version API. Read exact retention/legal-hold APIs only for profiles that claim them; map +ambiguous permission/error results to unknown. The normal application context has zero +`ObjectPurgeMaintenancePort` beans. Only an explicit protected maintenance context/configuration +with separately compiled binding, workload credentials, S3 client, contribution/assembler, and +close lifecycle may expose the privileged router. Construction happens only after selected +attestation/settings validation and never reuses or casts the normal contribution. Tests cover +disabled/unselected/invalid/selected/close and prove the normal context performs zero privileged +credential lookup/client creation. ArchUnit forbids injection outside approved maintenance/ +bootstrap packages. Keep purge disabled/report-only at composition until Task 27 and protected +security tests pass. + +- [ ] **Step 4: Verify GREEN** + +Run the command from Step 2. Expected: PASS. Do not promote the retention card from mocked tests. + +### Task 27: Add fenced reconciliation, report-first cleanup, and epoch compaction + +**Files:** + +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/`: + `ObjectStorageMaintenanceLease.java`, `ObjectStorageMaintenanceLeaseStore.java`, + `ObjectStorageCleanupPlanner.java`, `ObjectStorageCleanupCandidate.java`, + `ObjectStorageMaintenanceRunner.java`, `ObjectStorageCleanupMode.java`, + `ObjectOperationReconciler.java`, `ObjectMultipartReaper.java`, + `ObjectRetiredVersionReaper.java`, `ObjectOperationEpochCompactor.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/`: + `ObjectStorageMaintenanceSettings.java`, `ObjectStorageMaintenanceConfig.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilitySettings.java` +- Modify: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodec.java` +- Modify: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecordCodecTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageMaintenanceLeaseTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageCleanupPlannerTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectStorageMaintenanceRaceTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/ObjectOperationEpochCompactorTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceConfigTest.java` + +- [ ] **Step 1: Write failing lease/candidate tests** + +Prove: + +- one current `(owner token, fence, expiry)` controls a destination/job; +- expired owner cannot update/delete after takeover; +- LIST discovers candidates only; exact control/data/reference/session GET establishes truth; +- age, LIST absence, worker lease expiry, or missing application row alone never authorizes delete; +- candidate needs supported schema, terminal/eligible state, exact version, retention result, handoff + authorization, replay horizon, and no active direct/scan/multipart generation; +- unknown/newer/corrupt records are report-only quarantine. + +- [ ] **Step 2: Write failing cleanup/late-operation races** + +Cover cleanup versus late finalize, grant in-flight horizon, part acknowledgement, DB PENDING CAS, +retention activation, legal hold, claim renewal, operation response loss, and process kill at every +report/quarantine/delete/control-CAS step. A stale worker must not remove an artifact committed by a +newer fence. + +- [ ] **Step 3: Write failing epoch-compaction tests** + +Prove: + +- only sealed epochs beyond all replay/indeterminate/retention horizons compact; +- an immutable rejection record is durable before per-operation tombstones are removed; +- any operation in a sealed/compacted epoch returns `OPERATION_EXPIRED`; +- active/draining epoch, live session/reference, missing old binding/policy revision, or unknown + schema blocks compaction; +- epoch tokens are never reused. + +- [ ] **Step 4: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageMaintenanceLeaseTest' \ + --tests '*ObjectStorageCleanupPlannerTest' \ + --tests '*ObjectStorageMaintenanceRaceTest' \ + --tests '*ObjectOperationEpochCompactorTest' \ + --tests '*ObjectStorageMaintenanceConfigTest' \ + --tests '*ObjectControlRecordCodecTest' --console=plain +``` + +Expected: compilation failure because maintenance coordination does not exist. + +- [ ] **Step 5: Implement report-only first** + +Default maintenance is disabled; first production activation is `REPORT_ONLY`. `QUARANTINE` and +`DELETE` require explicit reviewed settings and exact qualified provider/card evidence; `DELETE` +also remains unavailable until Task 29 protected qualification and an explicit runtime approval +token. Constructor-bound settings cover enabled/mode, lease/renew, batch size, scan/list pages, +operation deadline, retry, concurrency, and shutdown grace. Invalid/unbounded combinations fail +before any provider/list/credential work, and disabled composition creates no runner/scheduler/ +privileged bean. Persist audit decisions before destructive I/O and outcome evidence after. +Register maintenance lease/compaction record families explicitly in the closed codec with golden +and old-reader fixtures. + +- [ ] **Step 6: Verify GREEN** + +Run the command from Step 4. Expected: PASS. + +### Task 28: Add low-cardinality observability, readiness, resource bounds, and graceful lifecycle + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/observability/`: + `ObjectStorageMetrics.java`, `ObjectStorageAuditSink.java`, + `LoggingObjectStorageAuditSink.java`, `ObjectStorageTelemetryRedactor.java` +- Create under + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/`: + `ObjectStorageReadinessProbe.java`, `ObjectStorageReadinessSnapshot.java`, + `ObjectStorageReadinessFailure.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLifecycleCoordinator.java` +- Create: + `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageActuatorConfig.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/observability/ObjectStorageObservabilityTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessProbeTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageActuatorConfigTest.java` +- Test: + `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLifecycleCoordinatorTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageResourceTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/ObjectStorageResourceBoundTest.java` +- Modify: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` +- Modify: + `.github/scripts/verify-gate-matrix.sh` +- Modify: + `docs/registries/metrics.yaml` + +- [ ] **Step 1: Register the non-skipping resource lane and locks** + +Register `objectStorageResourceTest`, add its secret-free bounded-resource job/release dependency to +the workflow/gate matrix, and lock the new configurations before behavioral RED: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +``` + +Missing resource prerequisites fail the selected task; the task never silently skips. + +- [ ] **Step 2: Write failing observability/redaction tests** + +Cover logical operations versus physical attempts, bytes/chunks, latency, outcome/certainty, +admission rejection, pool acquire, retry amplification, indeterminate age, orphan/scan/multipart +age, evidence expiry, and cleanup decisions. Tags may include only bounded card/provider/profile/ +operation/outcome values. Reject raw destination, tenant, reference, key, filename, bucket, +endpoint, request ID, URL/query, credential, ETag, upload ID, or exception message as metric tags. + +Captured logs/traces/audits must redact the same sensitive values while retaining hashed +correlation tokens and normalized outcomes. + +- [ ] **Step 3: Write failing readiness/lifecycle tests** + +Prove: + +- only enabled required destinations affect readiness; +- liveness does not depend on object storage; +- expired/mismatched qualification blocks new mutation/grant and reports exact redacted cause; +- optional destination outage follows its reviewed policy without changing required claims; +- disabled capability registers no probe/health/metrics/client; +- shutdown stops admission, drains managed callbacks within grace, persists cancellation or + indeterminate state, leaves durable direct sessions for recovery, releases maintenance lease, + closes presigner/client/executors exactly once, and leaks no thread/FD/buffer. + +- [ ] **Step 4: Write the failing resource task** + +Test increasing object size without linear heap growth; configured aggregate chunk/pool/multipart +bounds; slow producer/consumer; pool saturation; retry storm; concurrent range reads; in-flight +shutdown; direct memory, thread, and FD stability. The selected resource task may not silently skip. + +- [ ] **Step 5: Verify RED** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test \ + --tests '*ObjectStorageObservabilityTest' \ + --tests '*ObjectStorageReadinessProbeTest' \ + --tests '*ObjectStorageActuatorConfigTest' \ + --tests '*ObjectStorageLifecycleCoordinatorTest' --console=plain +./gradlew :adapter:outbound:objectstorage:objectStorageResourceTest --console=plain +``` + +Expected: compilation/task failure because observability/readiness/resource/lifecycle support does +not exist. + +- [ ] **Step 6: Implement with reviewed dependencies** + +Add `io.micrometer:micrometer-core` under existing dependency management, update the lockfile, and +apply registry names/cardinality bounds. Add the Actuator API as `compileOnly` plus +`testImplementation` and register the adapter's health bridge only when Actuator is present and the +capability is enabled; the runtime consumer already supplies Actuator. The objectstorage leaf must +not depend on `sample-portfolio`. Do not add an `app-bootstrap` project edge. + +- [ ] **Step 7: Verify GREEN and locks** + +Run: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +git diff -- adapter/outbound/objectstorage/gradle.lockfile +./gradlew \ + :adapter:outbound:objectstorage:test \ + :adapter:outbound:objectstorage:objectStorageResourceTest \ + :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +``` + +Expected: only the reviewed Micrometer/Actuator/resource-source-set lock delta, then PASS. + +### Task 29: Run protected AWS security/fault qualification and promote exact cards only + +**Files:** + +- Modify: + `src/adapter/outbound/objectstorage/build.gradle` +- Modify: + `src/adapter/outbound/objectstorage/gradle.lockfile` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageSecurityTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3SecurityQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ResponseLossQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3VersionRetentionQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3CredentialLifecycleQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ProcessCrashQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3BackupRestoreReconciliationQualificationTest.java` +- Create: + `src/adapter/outbound/objectstorage/src/test/resources/object-storage/aws-provider-evidence.json` +- Modify: + `.github/workflows/object-storage-qualification.yml` +- Modify: + `.github/ci-gate-matrix.yml` +- Modify: + `.github/scripts/verify-gate-matrix.sh` +- Modify: + `docs/registries/object-storage-readiness.yaml` + +- [ ] **Step 1: Register protected, non-skipping tasks** + +`objectStorageSecurityTest` and `objectStorageAwsQualificationTest` must require the approved +profile and exact attestation. Missing Docker/AWS/IaC/credential inputs fail when the lane is +selected. Never put account IDs, credentials, KMS material, or signed URLs in reports. + +Regenerate/review locks for the protected configurations and verify the gate matrix before any +external call: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks +./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +``` + +The protected workflow is manual/release-gated behind an approved environment and executes the +managed/common test from Task 16, direct test from Task 19, and all Phase 6 tests. Scheduled MinIO +fault remains separate. Retained artifacts are normalized card/result/evidence digests with expiry, +never secrets or raw topology. + +- [ ] **Step 2: Execute the full matrix** + +The exact AWS provider/profile/card matrix must include, as relevant: + +- real TLS/network/expected-owner/workload-role and credential refresh/rotation; +- BPA/ownership/private ACL negative tests; +- SSE-S3/SSE-KMS/DSSE profile and KMS deny/throttle/disabled-key behavior; +- conditional data/control mutation, checksum, versioning propagation soak/sentinel, delete + markers, and noncurrent exact purge; +- lifecycle/incomplete multipart; when and only when the retention card is proposed, mandatory + Object Lock, exact retention/legal-hold reads, normal-role governance-bypass denial, privileged + exact-version purge, and a generic-403 negative test proving it is not mapped to `HELD`; +- grant expiry versus credential horizon/qualified skew and an IaC-enforced bounded + `s3:signatureAge` positive/negative test; unhealthy time blocks new grant admission; +- DNS/connect/TLS/acquire/read/write/API deadline and SDK retry amplification; +- throttle, permission revocation, response drop, process halt/restart, rolling control schema; +- managed/direct single/multipart, download/range, quarantine only if a real scanner exists, + retention, and reconciliation card-specific requirements; +- bounded export/restore of exact data, manifests, reference pointers, operation/control records, + and version evidence into the authorized disposable restored namespace, followed by + reconciliation and digest/reference integrity audit; +- observability/redaction and no silent skip. + +- [ ] **Step 3: Run protected commands** + +Run in the authorized lane: + +```bash +cd src +./gradlew \ + :adapter:outbound:objectstorage:objectStorageSecurityTest \ + :adapter:outbound:objectstorage:objectStorageAwsQualificationTest \ + :adapter:outbound:objectstorage:objectStorageResourceTest \ + --console=plain +``` + +Expected: PASS only for the exact tested combinations. A partial failure leaves that card/profile at +its previous level; it does not lower requirements or borrow evidence from another card. +Regional/cluster DR is not inferred from the bounded restored-namespace test. + +- [ ] **Step 4: Update signed evidence and readiness registry** + +For each promoted row record: + +```text +card_id +provider exact type/version/deployment identity +destination profile +R2 +evidence revision and expiry +required non-skipping task names/results +limitations +attestation digest +``` + +Do not write a module-global R2 statement. In particular, scanner absence keeps +`object-storage-quarantine-publication` below R2. All direct upload/download cards remain below R2 +even if provider qualification passes because this plan implements no public direct endpoint, +authorization/rate limiting, or direct API snapshot. A reconciliation row may reach R2 only when +the bounded backup/restore qualification passes; retention may reach R2 only when every mandatory +Object-Lock/role/negative test above passes. + +### Task 30: Complete documentation, full gates, independent review, and Wiki capture + +**Files:** + +- Create: + `docs/runbooks/object-storage-startup-qualification-failed.md` +- Create: + `docs/runbooks/object-storage-managed-transfer-failed.md` +- Create: + `docs/runbooks/object-storage-indeterminate-operation.md` +- Create: + `docs/runbooks/object-storage-checksum-mismatch.md` +- Create: + `docs/runbooks/object-storage-scan-backlog.md` +- Create: + `docs/runbooks/object-storage-multipart-abandonment.md` +- Create: + `docs/runbooks/object-storage-orphan-backlog.md` +- Create: + `docs/runbooks/object-storage-retention-hold.md` +- Create: + `docs/runbooks/object-storage-credential-kms-failure.md` +- Create: + `docs/runbooks/object-storage-direct-grant-cors-incident.md` +- Create: + `docs/runbooks/object-storage-control-corruption-schema.md` +- Create: + `docs/runbooks/object-storage-cleanup-delete-kill-switch.md` +- Create: + `docs/runbooks/object-storage-local-filesystem-capacity.md` +- Create: + `docs/runbooks/object-storage-poster-handoff-stuck.md` +- Create: + `docs/runbooks/object-storage-epoch-compaction.md` +- Create: + `docs/runbooks/object-storage-backup-restore-reconciliation.md` +- Create: + `docs/runbooks/object-storage-provider-outage-upgrade.md` +- Create: + `docs/runbooks/object-storage-readiness-downgrade.md` +- Modify: + `src/adapter/outbound/objectstorage/README.md` +- Modify: + `src/adapter/outbound/objectstorage/CLAUDE.md` +- Modify: + `src/sample-portfolio/README.md` +- Modify: + `docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md` +- Modify: + `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` +- Modify: + `docs/superpowers/plans/2026-07-28-objectstorage-production-capability.md` +- Modify, only for actual fixed runtime placeholders: + `src/app-bootstrap/src/main/resources/application.yml`, + `src/.env`, `docs/registries/env-keys.yaml`, + `docs/registries/secrets-classification.yaml` +- Modify, when surfaced externally: + `docs/registries/error-codes.yaml`, `docs/registries/headers.yaml` + +- [ ] **Step 1: Write and exercise runbooks** + +Each runbook must include detection, scope, safe first actions, evidence to preserve, report-only +and admission-disable controls, exact reconciliation commands, escalation, and unsafe actions. +Exercise startup mismatch, credential expiry, KMS deny, response loss, checksum mismatch, scan +backlog, multipart/orphan cleanup, and retention hold in the relevant non-skipping lane. + +Maintain this design §40 traceability and exercise every row required by a claimed R2 card: + +| Incident family | Runbook owner | +| --- | --- | +| startup attestation/evidence mismatch, readiness downgrade | `startup-qualification-failed`, `readiness-downgrade` | +| TLS/certificate/DNS/VPC endpoint/pool saturation | `startup-qualification-failed`, `managed-transfer-failed`, `provider-outage-upgrade` | +| managed timeout/response loss/checksum | `managed-transfer-failed`, `indeterminate-operation`, `checksum-mismatch` | +| presigned URL leak/reissue/signature age/CORS drift | `direct-grant-cors-incident` | +| control corruption/newer schema/rolling reader | `control-corruption-schema` | +| scan backlog/malicious/indeterminate | `scan-backlog` | +| multipart/orphan/late grant | `multipart-abandonment`, `orphan-backlog` | +| cleanup DELETE enablement/process kill/stale fence | `cleanup-delete-kill-switch` | +| retention/legal hold/privileged purge | `retention-hold` | +| versioning suspended/delete-marker or noncurrent-version growth | `retention-hold`, `readiness-downgrade` | +| credential expiry/rotation and KMS deny/throttle | `credential-kms-failure` | +| local filesystem disk/inode/permission | `local-filesystem-capacity` | +| UploadIntent/handoff/retirement stuck | `poster-handoff-stuck` | +| epoch seal/compaction/replay expiry | `epoch-compaction` | +| bounded backup/restore reconciliation | `backup-restore-reconciliation` | +| provider outage/upgrade/rollback | `provider-outage-upgrade` | +| graceful shutdown/in-flight drain/resource leak | `managed-transfer-failed`, `readiness-downgrade` | +| operation/reference lookup hot partition or prefix imbalance | `provider-outage-upgrade`, `readiness-downgrade` | + +- [ ] **Step 2: Reconcile settings/env/secrets truthfully** + +Because `app-bootstrap` has no objectstorage project edge in this plan, do not add orphan +object-storage placeholders to its YAML or `src/.env`. Protected qualification inputs belong to CI +secret/config, not application env registries. If a future approved production owner adds the edge, +that separate plan must update `modules.json`, `app-bootstrap/build.gradle`, application YAML, +`.env`, env/secrets registries, settings tests, and `verifyEnvKeys` together. + +Document canonical settings and explicit sample-local YAML without inventing static production +credentials. Update error/header registries only for fields actually exposed by the approved sample +API. + +- [ ] **Step 3: Run focused and integration gates** + +Run: + +```bash +cd src +./gradlew :application-core:check --console=plain +./gradlew :adapter:outbound:persistence-jpa:check --console=plain +./gradlew :adapter:outbound:objectstorage:check --console=plain +./gradlew :sample-portfolio:check --console=plain +./gradlew :sample-portfolio:posterImageMigrationTest --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew :app-bootstrap:sampleOffTest --console=plain +./gradlew \ + :adapter:outbound:objectstorage:objectStorageMinioContractTest \ + :adapter:outbound:objectstorage:objectStorageMinioFaultTest \ + :adapter:outbound:objectstorage:objectStorageSecurityTest \ + :adapter:outbound:objectstorage:objectStorageAwsQualificationTest \ + :adapter:outbound:objectstorage:objectStorageResourceTest \ + --console=plain +``` + +Expected: all selected required tasks PASS; no selected readiness task skips. +Assert the `posterImageMigrationTest` XML has zero skipped tests. + +- [ ] **Step 4: Run repository-wide gates** + +Run: + +```bash +cd src +./gradlew test --console=plain +./gradlew check --console=plain +./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --console=plain +./gradlew \ + :application-core:verifyDependencyLocks \ + :adapter:outbound:objectstorage:verifyDependencyLocks \ + :sample-portfolio:verifyDependencyLocks \ + :app-bootstrap:verifyDependencyLocks \ + verifyCleanArchitectureDependencies \ + verifyPublicPathSnapshot \ + verifyEnvKeys --console=plain +cd .. +bash .github/scripts/verify-gate-matrix.sh +git diff --check +``` + +Expected: all commands PASS. If an unrelated dirty-file check fails, preserve it, report exact +ownership/evidence, and do not claim the repository-wide gate passed. + +- [ ] **Step 5: Perform independent review** + +Review at least these tracks independently: + +1. application/transport/domain boundary and module edges; +2. operation fingerprint, state machine, CAS, response-loss and crash-gap recovery; +3. provider exactness, checksum/multipart/presign/version/retention semantics; +4. configuration, credentials, TLS, attestation, redaction, resource/lifecycle safety; +5. sample DB/API migration, rollback, dual read, retirement; +6. readiness claims versus actual non-skipping evidence. + +Resolve every blocker/high (or Critical/Important) finding and rerun affected focused plus full +gates. + +- [ ] **Step 6: Capture the required LLM Wiki record** + +Before the implementation completion response, read the canonical vault instructions and update: + +```text +/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/.md +``` + +Record files, decisions, test commands/results, failures/blocks, evidence grade, and derivative +raw notes with bidirectional links. If the canonical vault is absent or inaccessible, do not use a +different clone; record the exact block in the plan/design and final response. + +- [ ] **Step 7: Report exact completion** + +The handoff must list changed files, implementation slices, exact commands/results, failures or +unrun protected lanes, Wiki capture, card/provider/profile/evidence tuples, rollback posture, and +remaining gates. Never use “Objectstorage R2” as an unqualified completion statement. + +--- + +## Phase 7 follow-up plan, not executable here + +Create a separately approved plan only after Phase 6 evidence exists. It must cover: + +- multi-node operation/maintenance fencing and failover; +- rolling writer/reader schema compatibility and old binding/policy restoration; +- regional/cluster disaster-recovery game day and failover restore integrity audit, building on but + not replaced by Phase 6's bounded single-destination restored-namespace R2 test; +- sustained load, quota/capacity exhaustion, credential/KMS rotation under load; +- provider upgrade/rollback and operational game day; +- a split/no-split ADR based on actual dependency/release/security/runtime divergence. + +Until that plan passes, no exact card may claim R3. Disabling optional cards and returning new +admission to the last qualified provider/schema revision is the rollback posture; live operation, +reference, session, and audit records remain readable/reconcilable. diff --git a/docs/superpowers/plans/2026-07-28-redis-cache-resilience.md b/docs/superpowers/plans/2026-07-28-redis-cache-resilience.md new file mode 100644 index 0000000..fd4974f --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-redis-cache-resilience.md @@ -0,0 +1,52 @@ +# Redis Cache Resilience Implementation Plan + +> Repository commit policy is human-only. Do not stage, commit, amend or push. + +**Goal:** Implement the approved cache-aside, bounded source protection and soft/hard TTL design +without promoting Redis beyond standalone cache R1. + +### Task 1: Application cache-aside outcomes and policy + +**Files:** +- Create/modify `src/application-core/src/main/java/dev/caskeleton/application/cache/*` +- Test `src/application-core/src/test/java/dev/caskeleton/application/cache/*` + +- [x] Write RED tests for fresh/negative/miss/stale/source outcome transitions. +- [x] Add typed loader, failure, result, cancellation and immutable policy contracts. +- [x] Implement cache-aside sequencing; only authoritative absence may be negative-cached. +- [x] Preserve unclassified exceptions and interruption. +- [x] Verify focused application cache tests GREEN. + +### Task 2: Bounded local single-flight and source bulkhead + +**Files:** +- Create `CacheSingleFlight.java` +- Create `CacheSourceBulkhead.java` +- Test their concurrency behavior through focused unit tests. + +- [x] Write RED concurrency tests. +- [x] Bound in-flight keys, waiters, admission wait and load wait. +- [x] Remove completed/failed/abandoned flights and preserve loader failure fan-out. +- [x] Prove Redis outage cannot create unlimited source concurrency. + +### Task 3: Redis soft/hard TTL, jitter and stale envelope + +**Files:** +- Modify `RedisCacheRegionPolicy.java` +- Modify `RedisCacheEnvelopeCodec.java` +- Modify `RedisStringCacheRegion.java` +- Modify/add focused Redis cache tests. + +- [x] Write RED boundary, jitter, minimum and schema-compatibility tests. +- [x] Add an injected `Clock` and deterministic policy-revision jitter. +- [x] Encode absolute soft/hard expiry in envelope version 2. +- [x] Use the encoded hard expiry as physical Redis TTL. +- [x] Verify focused Redis tests GREEN. + +### Task 4: Documentation and verification + +- [x] Synchronize the completed foundation-plan checkboxes with existing code/evidence. +- [x] Update Redis README/CLAUDE/design readiness truth. +- [ ] Run application and Redis leaf checks. +- [ ] Run dependency locks, architecture, public path, env and diff checks. +- [x] Request independent specification and code-quality review. diff --git a/docs/superpowers/plans/2026-07-28-redis-distributed-rate-limit.md b/docs/superpowers/plans/2026-07-28-redis-distributed-rate-limit.md new file mode 100644 index 0000000..ddb47b8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-redis-distributed-rate-limit.md @@ -0,0 +1,45 @@ +# Redis Distributed Rate-Limit Implementation Plan + +> Repository commit policy is human-only. Do not stage, commit, amend or push. + +### Task 1: Shared edge rate-limit contract + +- [x] Write RED contract/policy tests in `shared-contract`. +- [x] Add bounded request, algorithm parameters, policy, decision, outcome and port types. +- [x] Reject unsupported dedup/failure claims and unsafe fixed-point arithmetic. +- [x] Verify the shared contract without Redis/Spring types. + +### Task 2: Structured Redis program execution + +- [x] Write RED tests for MULTI reply arity/status/ASCII integer bounds and `NOSCRIPT`. +- [x] Add bounded structured `EVALSHA`/`EVAL` command support without changing scalar primitives. +- [x] Add exact catalog descriptors and resource digests for three rate programs. + +### Task 3: Three atomic algorithms and semantic provider + +- [x] Implement fixed-window Lua and golden vectors. +- [x] Implement sliding-counter Lua with conservative fixed-point arithmetic. +- [x] Implement token-bucket Lua with saturation and exact ceiling retry. +- [x] Add canonical private keys, policy lookup and typed failure mapping. +- [x] Prove denial does not consume quota and revision changes physical state. + +### Task 4: Dedicated runtime and explicit composition + +- [x] Add strict `app.rate-limit` settings and disabled-zero-side-effect configuration. +- [x] Use a dedicated coordination runtime rather than cache Redis beans/settings. +- [x] Add exact environment registry/application configuration entries. +- [x] Keep readiness at standalone provider R1. + +### Task 5: Verification and review + +- [x] Run shared/Redis/bootstrap focused checks. +- [x] Run architecture/dependency/env/diff gates. +- [ ] Run the public-path gate with the final combined change set. +- [x] Run an explicit real Redis lane when a service is available. +- [x] Request independent spec and quality review. + +The Redis 7.4 service lane executes the exact-boundary admission after a denied non-consuming +request for all three algorithms, excessive clock-regression state immutability, token refill +remainder carry, malformed hash classification, cache NX, and observation-token compare-replace. +The program manifests therefore declare 7.4 as the minimum qualified version until a lower-version +service lane exists. diff --git a/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md b/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md index fd0347f..03cef9c 100644 --- a/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md +++ b/docs/superpowers/plans/2026-07-28-redis-production-capability-foundation.md @@ -31,10 +31,10 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p - Create: `src/application-core/src/main/java/dev/caskeleton/application/cache/AuthoritativeAbsence.java` - Test: `src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java` -- [ ] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata. -- [ ] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`. -- [ ] Implement only framework-free values and ports. -- [ ] Verify GREEN. +- [x] Write a failing test for hit/negative/miss/unavailable distinctions and immutable metadata. +- [x] Verify RED with `./gradlew :application-core:test --tests '*CacheRegionContractTest'`. +- [x] Implement only framework-free values and ports. +- [x] Verify GREEN. ### Task 2: Add canonical Redis physical keys @@ -45,12 +45,12 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p - Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyDigest.java` - Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilderTest.java` -- [ ] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and +- [x] Write a failing test proving namespace isolation, one stable hash tag, bounded key bytes, and absence of raw sensitive resource identifiers. -- [ ] Verify RED. -- [ ] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret +- [x] Verify RED. +- [x] Implement SHA-256 for opaque IDs and HMAC-SHA-256 for sensitive scopes using defensive secret copies and length-prefixed component encoding. -- [ ] Verify GREEN. +- [x] Verify GREEN. ### Task 3: Add a typed, versioned atomic-program catalog @@ -66,11 +66,11 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p - Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisProgramCatalogTest.java` - Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/program/RedisAtomicPrimitivesTest.java` -- [ ] Write failing catalog and facade tests. -- [ ] Verify RED. -- [ ] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic +- [x] Write failing catalog and facade tests. +- [x] Verify RED. +- [x] Implement exact resource digest, key/argument bounds, typed status mapping, and no generic application-facing execution surface. -- [ ] Verify GREEN. +- [x] Verify GREEN. ### Task 4: Record exact readiness and verify @@ -79,9 +79,9 @@ idempotency, lease, session, and R2/R3 evidence remain separate implementation p - Modify: `src/adapter/outbound/cache-redis/CLAUDE.md` - Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md` -- [ ] Mark only contract/key/program foundation as implemented and all real runtime/capability +- [x] Mark only contract/key/program foundation as implemented and all real runtime/capability promotion as unimplemented. -- [ ] Run: +- [x] Run: ```bash cd src @@ -89,5 +89,5 @@ cd src ./gradlew verifyCleanArchitectureDependencies --console=plain ``` -- [ ] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence +- [x] Do not claim Redis cache R1/R2 until a real standalone service lane and codec/runtime evidence exist. diff --git a/docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md b/docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md new file mode 100644 index 0000000..5eaff3b --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md @@ -0,0 +1,662 @@ +# Redis Production Capability Completion Plan + +> **Scope:** Redis를 먼저 완료한다. 현재 실행 단위는 deep design Phase 5 전체가 아니라 +> `Sentinel-first R2 qualification slice`다. 이 slice의 검증과 보고가 끝나면 멈추고 +> fileserver, HTTP client, Redis Cluster/R3 중 다음 우선순위를 다시 정한다. +> +> **Workflow note:** 저장소가 지정한 Superpowers 설계·계획·TDD·디버깅·검증·리뷰 워크플로우를 +> 적용한다. agent는 human-only commit 정책에 따라 stage/commit/amend/push하지 않는다. + +**Goal:** `2026-07-26-redis-production-capability-design.md`의 Phase 1–5를 capability별로 구현하고, +standalone 기능의 존재를 production readiness로 오표기하지 않는 Redis platform을 만든다. + +**Architecture:** `application-core`와 `shared-contract`는 provider-neutral semantic contract만 +소유한다. `adapter:outbound:cache-redis`가 Redis deployment, topology, key, codec, program, +runtime과 capability provider를 소유한다. `adapter:inbound:web`은 HTTP rate/session 보안 매핑만, +`app-bootstrap`은 provider/role/auth-mode composition만 소유한다. `domain-core`에는 Redis 개념을 +추가하지 않는다. + +**Readiness rule:** Redis leaf 전체에 단일 R2 label을 부여하지 않는다. `redis-cache`, +`redis-edge-rate-limit`, `redis-request-replay-idempotency`, +`redis-cache-refresh-soft-lease`, `redis-fenced-coordination`, `redis-session` card가 독립적으로 +승격한다. R3 증거가 없는 failover/reshard/rotation은 R2 범위로 과장하지 않는다. + +**Worktree rule:** 현재 `main` worktree의 다른 기술 변경은 사용자 소유다. Redis가 소유하지 않는 +fileserver, HTTP client, messaging, notification, object storage 변경을 되돌리거나 포맷하지 않는다. + +**Current milestone exit:** agent-side 목표는 `R2-ready candidate`다. clean committed source와 +실제 remote GitHub Actions evidence가 없으면 card를 `selected`로 바꾸거나 R2라고 주장하지 않는다. + +--- + +## Task 0 — Baseline과 acceptance registry 고정 + +**Files** + +- Create: `src/config/redis/readiness-cards.yaml` +- Create: `src/gradle/redis-test-images.properties` +- Modify: `src/adapter/outbound/cache-redis/README.md` +- Modify: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md` + +**Tests first** + +- registry가 canonical card ID 여섯 개를 정확히 한 번 포함하는지 실패 테스트를 작성한다. +- image tag에 exact version과 digest가 없으면 configuration이 실패하는 테스트를 작성한다. +- `selected`, `implemented-candidate`, `not-implemented` 이외 상태를 거절한다. +- 현재 구현과 다른 readiness 표기를 거절한다. + +**Implementation** + +- 시작 상태는 cache/rate를 `implemented-candidate`, 나머지는 `not-implemented`로 기록한다. +- 실제 required evidence가 생기기 전에는 어떤 card도 `selected` R2로 승격하지 않는다. +- Redis minimum version은 실행 가능한 image/digest와 program manifest를 한 SSOT로 맞춘다. + +**Verification** + +```bash +cd src +./gradlew :adapter:outbound:cache-redis:test --tests '*RedisReadinessRegistryTest' --console=plain +``` + +## Task 1 — Canonical deployment/topology/role model + +**Files** + +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderProperties.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java` +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java` +- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java` +- Test: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderPropertiesBindingTest.java` + +**Tests first** + +- topology는 `standalone|sentinel|cluster` 중 정확히 하나다. +- endpoint는 non-empty, unique, bounded host/port다. +- Sentinel은 master name, 최소 3개 discovery endpoint, data/Sentinel auth와 TLS를 분리한다. +- Cluster는 database 0만 허용하고 seed가 비어 있으면 실패한다. +- role은 존재하는 deployment만 참조한다. +- cache와 session/coordination의 incompatible co-location을 startup 전에 거절한다. +- provider 정의만 있고 capability binding이 없으면 runtime side effect가 0이다. + +**Implementation** + +- Spring binding class와 validated sealed runtime model을 분리한다. +- legacy `app.cache.redis`와 `app.rate-limit`은 migration compiler 입력으로만 허용하고 canonical + model과 동시에 설정되면 precedence를 정하지 않고 실패한다. +- `ClientMode.EXTERNAL`을 topology로 취급하지 않는다. + +**Verification** + +```bash +cd src +./gradlew :adapter:outbound:cache-redis:test --tests '*RedisDeploymentSettings*' --console=plain +``` + +## Task 2 — Topology-aware runtime, TLS/ACL과 secret material + +**Files** + +- Create: `.../redis/runtime/RedisDeploymentRuntime.java` +- Create: `.../redis/runtime/RedisDeploymentRuntimeFactory.java` +- Create: `.../redis/runtime/StandaloneRedisDeploymentRuntime.java` +- Create: `.../redis/runtime/SentinelRedisDeploymentRuntime.java` +- Create: `.../redis/runtime/ClusterRedisDeploymentRuntime.java` +- Create: `.../redis/security/RedisCredentialMaterialProvider.java` +- Create: `.../redis/security/RedisCredentialRotationCoordinator.java` +- Modify: `src/adapter/outbound/cache-redis/build.gradle` +- Modify: `src/adapter/outbound/cache-redis/gradle.lockfile` + +**Tests first** + +- standalone/Sentinel/Cluster가 각자 다른 native client/runtime을 만든다. +- Sentinel discovery credential/trust와 data-node credential/trust가 섞이지 않는다. +- Cluster client는 periodic+adaptive topology refresh, DB 0, bounded redirect/queue profile을 가진다. +- production profile에서 plaintext, trust-all, hostname verification off를 거절한다. +- named ACL username이 없거나 raw password가 YAML에 있으면 production activation이 실패한다. +- duplicate/out-of-order rotation event, expiry 재조회, new connection 검증 실패가 old traffic을 + 안전하게 보존한다. +- disabled capability는 client/event-loop/subscriber/scheduler를 만들지 않는다. + +**Implementation** + +- direct `spring-data-redis`, `lettuce-core` dependency를 leaf가 소유한다. +- deployment별 client resources와 lifecycle을 소유한다. +- connect/TLS/acquire/command/overall/shutdown timeout을 분리한다. +- 기존 no-replay, disconnected reject, finite queue/count/byte admission을 topology runtime에도 + 보존한다. +- secret value/reference/provider exception을 log/metric에 남기지 않는다. + +## Task 3 — Key, codec, program manifest foundation + +**Files** + +- Create: `src/config/redis/program-set.schema.json` +- Modify: `src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json` +- Modify: `src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json` +- Modify: `.../redis/RedisProgramDescriptor.java` +- Modify: `.../redis/RedisProgramCatalog.java` +- Modify: `.../redis/RedisLuaProgramExecutor.java` +- Create: `.../redis/key/RedisKeyMaterialProvider.java` +- Create: `.../redis/codec/RedisCapabilityCodec.java` + +**Tests first** + +- 모든 program은 exact source digest, semantic version, ordered KEYS/ARGV, result schema, slot rule, + state/TTL bound, minimum Redis version, retry/certainty, ACL command를 가진다. +- manifest와 Java descriptor가 drift하면 build가 실패한다. +- `NOSCRIPT` recovery는 bounded `SCRIPT LOAD -> EVALSHA`이고 arbitrary source 실행 surface가 없다. +- same-resource multi-key는 real `CLUSTER KEYSLOT`과 같은 slot이다. +- key digest material rotation은 fixed/dual-read-delete/cold-cutover rule을 지킨다. +- cache/idempotency/session codec은 N/N-1, future/corrupt/oversize/forbidden type을 구분한다. + +**Implementation** + +- foundation/rate manifest를 하나의 versioned registry contract로 통합하되 capability package와 + facade는 분리한다. +- raw command, raw key, generic program executor를 Spring/application public surface에 노출하지 않는다. + +## Task 4 — Cache consistency spine와 semantic region composition + +**Files** + +- Modify: `src/application-core/src/main/java/dev/caskeleton/application/cache/*` +- Create: `.../redis/cache/RedisCacheGenerationStore.java` +- Create: `.../redis/cache/RedisCacheRegionCompiler.java` +- Add resources: `region-generation-init-v1.lua`, `region-generation-bump-v1.lua`, + `cache-record-if-generation-v1.lua` +- Modify: `.../redis/RedisStringCacheRegion.java` +- Tests: application barrier tests, Redis real-service concurrency tests, binding tests + +**Tests first** + +- source load 중 generation bump가 일어나면 old result가 visible하지 않다. +- captured generation과 source revision이 바뀌면 stale writer가 새 값을 덮어쓰지 않는다. +- generation init race에서 하나의 canonical generation만 선택된다. +- operation ID가 같은 bump replay는 한 번만 적용된다. +- 여러 semantic region의 duplicate/missing binding은 fail-fast다. +- 실제 consumer가 semantic `CacheRegionPort`와 `CacheAsideExecutor`를 사용하고 legacy fail-open + router와 암묵적으로 섞이지 않는다. + +**Implementation decision** + +- source revision은 opaque하므로 lexical “newer” 비교를 하지 않는다. +- region generation은 mass invalidation fence다. +- per-key invalidation은 해당 key의 revision/tombstone fence를 사용해 region 전체를 bump하지 않는다. +- write는 captured generation/revision condition을 만족할 때만 기록한다. + +## Task 5 — Distributed refresh soft lease, L1/L2와 cache observability + +**Files** + +- Create application cache refresh coordination contracts without Redis types. +- Create Redis refresh claim/release programs and semantic provider. +- Create bounded L1 cache decorator and invalidation subscriber/reconciler. +- Create framework-free cache observation events and Micrometer adapter instrumentation. +- Update `docs/registries/metrics.yaml`. + +**Tests first** + +- 두 pod simulation에서 정상 시 refresh owner는 하나다. +- lease expiry에서는 duplicate load를 허용하지만 generation guard가 stale write를 차단한다. +- disconnected invalidation subscriber는 L1을 flush하고 generation을 재확인한다. +- Pub/Sub event loss에도 L1 TTL/generation reconciliation으로 stale bound를 지킨다. +- L1 max weight/cardinality/TTL, subscriber queue, refresh scheduler가 모두 bounded다. +- Redis liveness는 애플리케이션 liveness를 내리지 않는다. +- optional cache outage는 `DEGRADED`, required coordination/session outage는 `NOT_READY`다. +- cache role eviction/OOM에서 source concurrency와 queue가 bounded다. + +## Task 6 — Edge rate limit end-to-end + +**Files** + +- Modify: `src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/*` +- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/*` +- Modify: `src/adapter/outbound/cache-redis/src/main/java/.../redis/*rate*` +- Modify: `src/app-bootstrap` composition + +**Tests first** + +- inbound가 process-local map이 아니라 `EdgeRateLimitPort`를 호출한다. +- subject는 raw principal/IP가 아닌 bounded pseudonymous digest다. +- fixed/sliding-counter/token-bucket reference/property/concurrency vector를 통과한다. +- evaluation ID replay가 quota를 두 번 소비하지 않는다. +- bounded local emergency는 configured degraded provider일 때만 동작한다. +- Redis/local/disabled provider exclusivity, shadow/degraded source, 429/503와 `Retry-After` mapping을 + 검증한다. +- legacy unbounded map과 silent primary fallback을 제거한다. + +## Task 7 — Idempotency v2와 Redis provider + +**Files** + +- Replace/extend `src/application-core/.../idempotency` with owner-safe v2 contracts. +- Add Redis idempotency state programs/provider/codec. +- Migrate the existing JPA provider to the same semantic contract only after checking its separate + worktree changes; never overwrite concurrent persistence work. + +**Tests first** + +- atomic claim, fingerprint mismatch, owner/attempt-safe start/renew/complete/fail/release/inspect. +- processing TTL과 replay TTL 분리. +- expired `CLAIMED` takeover, expired `EXECUTING -> RECOVERY_REQUIRED`. +- response-loss replay/reconciliation, conflicting response digest reject. +- unverified cross-store effect는 자동 discard/re-execution하지 않는다. +- JDBC/Redis provider가 같은 scope를 동시에 claim하지 않는다. + +**Implementation** + +- Redis가 cross-store exactly-once를 보장한다고 표현하지 않는다. +- JPA migration 충돌이 있으면 Redis completion의 명시적 integration blocker로 보고하고 해당 + worktree의 결과와 재대조한다. + +## Task 8 — Efficiency lease와 optional fenced coordination + +**Tests first** + +- acquire/inspect/renew/release가 owner+operation token을 비교한다. +- response loss는 `UNKNOWN/INDETERMINATE`이며 same token inspect로 reconcile한다. +- expired old owner는 renew/release할 수 없다. +- watchdog는 bounded scheduler와 cancellation을 사용하고 lost 상태를 전달한다. +- fenced card를 선택하면 durable epoch/high-watermark 등록과 protected-resource stale-token reject를 + 실제 fixture로 증명한다. + +**Implementation** + +- close-only `DistributedLock`은 compatibility facade로 유지하되 새 코드가 strong lock으로 + 오해하지 않게 guarantee를 명명한다. +- fencing 없는 Redis lease를 business correctness lock으로 광고하지 않는다. + +## Task 9 — Redis Session과 JWT/session exclusive composition + +**Files** + +- Add direct `spring-session-core` and `spring-session-data-redis` to Redis leaf. +- Add adapter-internal versioned session store/programs/serializer. +- Add inbound web cookie/CSRF/fixation settings and security configuration. +- Add app-bootstrap `jwt|redis-session` exclusive composition. + +**Tests first** + +- JWT mode는 session Redis connection/bean/thread side effect가 0이다. +- pod A create/save, pod B read/touch/logout. +- idle/absolute expiry, rotation, old ID reject, stale save after logout reject. +- explicit allowlisted serializer N/N-1 and corrupt payload re-auth. +- secure/httpOnly/SameSite/host-only cookie, CSRF enabled, fixation rotation. +- repository outage/noeviction OOM/failover는 fail-open 인증으로 바뀌지 않는다. +- indexed repository는 별도 opt-in이며 Cluster event cleanup 한계를 독립 검증한다. + +## Task 10 — Real-service, topology, fault와 readiness Gradle tasks + +**Files** + +- Create: `src/adapter/outbound/cache-redis/src/redisTest/**` +- Modify: `src/adapter/outbound/cache-redis/build.gradle` +- Modify: `src/build.gradle` +- Create/update Redis test topology resources and sanitized evidence reporter + +**Public tasks** + +- `redisStandaloneTest`, `redisSecurityTest`, `redisSentinelTest`, `redisClusterTest`, + `redisFaultTest`, `redisCompatibilityTest` +- capability card test/readiness tasks named exactly as Redis deep design §37.22 +- root `redisProductionReadiness`, `redisAllImplementedCandidates` + +**Rules** + +- selected evidence에서 Docker/service 부재나 0 discovered tests는 failure다. +- unselected card는 skipped가 아니라 `not selected`다. +- image/program/config digest와 sanitized JUnit/topology timeline을 evidence artifact로 남긴다. + +## Task 11 — Container topology와 3-node k3s qualification + +이번 실행은 deep design §37.13/Phase 5A의 Sentinel-first slice만 다룬다. Cluster, fenced +coordination, R3 long chaos/soak, k3s control-plane HA, physical host/AZ failure, full +credential/certificate rotation은 후속 작업이다. + +### Task 11.1 — Lab lifecycle contract와 host isolation RED + +이 작업은 리뷰 경계를 다음처럼 분리한다. 두 하위 작업이 모두 독립 리뷰를 통과하기 전에는 부모 +Task 11.1을 완료로 표시하지 않는다. + +- `Task 11.1A-1`: VM lifecycle, ownership marker/state, lock/signal/handoff cleanup, host + fingerprint와 bounded command. 현재 구현을 동결한다. +- `Task 11.1A-2`: pinned K3s generated-kubeconfig strict validator/renderer. 실행 계획은 + `docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md`를 따른다. + +2026-07-30 상태: `Task 11.1A-1` lifecycle/ownership과 `Task 11.1A-2` strict renderer는 +whole-task 독립 review에서 Critical `0`, Important `0`, Minor `0`, SPEC PASS / +QUALITY APPROVED를 받았다. fresh direct/Gradle fake-only 검증도 통과해 부모 `Task 11.1A`의 +fake-only 범위는 완료다. 이는 live VM/k3s/kubectl/network/host qualification이나 Redis +R2 readiness 완료를 의미하지 않는다. + +**Tracked files** + +- Create: `infra/redis-lab/README.md` +- Create: `infra/redis-lab/versions.env` +- Create: `infra/redis-lab/bin/redis-lab` +- Create: `infra/redis-lab/cloud-init/node.yaml` +- Create: `infra/redis-lab/test/redis-lab-contract.sh` +- Modify: Redis Gradle VM-free lifecycle contract task + +**Tests first** + +- VM 이름은 `ca-redis-lab-server`, `ca-redis-lab-agent-1`, + `ca-redis-lab-agent-2` exact allowlist만 허용한다. +- server 1 + agent 2, resource `2/3GiB/12GiB`, `2/2.5GiB/12GiB`, + `2/2.5GiB/12GiB`, pod CIDR `10.52.0.0/16`, service CIDR + `10.53.0.0/16`, context `ca-redis-lab`을 검증한다. +- host 관측은 default kubeconfig의 run-scoped copy와 원래 host context를 사용하고 read-only + allowlist만 허용한다. lab 호출은 별도 ignored `src/build/redis-lab/kubeconfig`와 exact + `ca-redis-lab` context를 사용한다. +- default kubeconfig merge/write, host context mutation, wildcard VM cleanup, global + `multipass purge`를 정적/동적 contract가 거절한다. +- preflight/postflight host kubeconfig/context/node/workload fingerprint가 다르면 실패한다. +- CI는 retain-on-failure를 거절하고, local opt-in만 exact VM 보존을 허용한다. +- fake `multipass`/`kubectl`을 주입하는 shell contract는 partial-create cleanup과 exact command + allowlist를 VM 생성 없이 검증하고 `redisLabContractTest`로 module `check`에 연결한다. +- launch 전 exact name을 run-owned `PENDING`으로 atomic 예약하고 성공 직후 `CREATED`로 + 승격한다. timeout/실패/상태 승격 실패는 이 run이 예약한 exact name만 정리한다. +- private run-scoped rendered cloud-init은 non-secret `RUN_ID|VM_NAME` ownership marker를 + 기록한다. cleanup/down은 bounded marker read가 state owner와 exact name 일치를 증명할 + 때만 delete한다. launch timeout/error는 `RECONCILE` tombstone과 bounded late-create poll로 + 처리하며 absent/unreadable/mismatch는 delete/state removal 없이 fail-closed한다. +- lifecycle 전체는 nonblocking exclusive lock과 run identity를 사용한다. direct `up`과 + `run` 모두 첫 launch 전 emergency cleanup을 활성화하고, signal/concurrent 실행이 다른 + run state나 VM을 채택·삭제하지 못한다. user command에는 lock file descriptor를 상속하지 + 않으며 기본 bounded external child도 FD를 닫고 lock acquisition만 예외로 유지한다. + `run`의 inner `up` 성공과 user command 시작 사이에도 cleanup-required flag가 연속 유지돼 + zero-ownership handoff gap이 없어야 한다. +- host kubeconfig copy는 fingerprint/CIDR 관측 범위가 끝나면 성공/실패와 무관하게 제거한다. +- lab kubeconfig renderer는 denylist/generic-count 보강을 사용하지 않는다. pinned K3s의 + canonical block-style one-cluster/context/user grammar를 별도 tracked AWK state machine으로 + allowlist하며, catch-all pass-through 없이 duplicate/extra/reordered/unknown/flow-style + identity와 모든 비허용 구조를 fail-closed로 거절한다. +- external command와 3-node Ready 대기는 bounded이고, host service CIDR은 assigned + ClusterIP에서 추측하지 않고 명시적 validated input 또는 신뢰 가능한 host 설정에서 얻는다. +- mutable `curl | sudo sh` installer는 금지한다. exact K3s release URL과 SHA-256을 repository에 + pin하고 host download와 각 VM transfer 뒤 다시 검증한 후에만 install/start한다. +- shell contract는 별도 fixture repository에서 실행하고 actual `src/build/redis-lab` canary를 + byte-for-byte 보존한다. fake PATH는 explicit safe wrapper 외 모든 명령을 fail-closed한다. + +### Task 11.2A — Sentinel manifest와 security static contract GREEN + +**Tracked files** + +- Create: `infra/redis-lab/config/redis.conf.tmpl` +- Create: `infra/redis-lab/config/sentinel.conf.tmpl` +- Create: `infra/redis-lab/config/redis-users.acl.tmpl` +- Create: `infra/redis-lab/config/sentinel-users.acl.tmpl` +- Create: `infra/redis-lab/k3s/namespace.yaml` +- Create: `infra/redis-lab/k3s/redis-data.yaml` +- Create: `infra/redis-lab/k3s/redis-sentinel.yaml` +- Create: `infra/redis-lab/k3s/network-policy.yaml` +- Create: + `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLabManifestContractTest.java` +- Modify: Redis Gradle manifest contract task + +- static contract와 live security evidence를 분리한다. YAML/템플릿 정적 통과는 TLS handshake, + ACL authorization, CNI enforcement, scheduling/failover의 실행 증거가 아니다. +- data Redis 3개와 Sentinel 3개는 각각 stable ordinal/headless DNS가 필요한 StatefulSet으로 + 구성하고 `kubernetes.io/hostname` required anti-affinity와 `maxSkew=1/DoNotSchedule` + topology spread, `podManagementPolicy: Parallel`을 적용한다. +- data는 PVC + AOF `appendfsync everysec`를 사용한다. Sentinel은 공식 동작상 writable config에 + discovery/failover 상태를 rewrite하므로, bootstrap source를 pod별 writable PVC config로 + 최초 1회 atomic init-copy하고 restart 때 기존 rewritten config를 덮어쓰지 않는다. + 비어 있거나 손상된 기존 config는 자동 복구로 덮지 않고 startup을 실패시킨다. +- Redis image SSOT는 `src/gradle/redis-test-images.properties`의 + `redis.minimum.image` exact tag+digest다. `redis.approved.image`나 임의 YAML image를 이 + minimum-version Sentinel slice에 섞지 않는다. +- plaintext port는 data/Sentinel 모두 0이고 TLS port만 연다. `tls-replication yes`, + hostname resolution/announcement와 certificate SAN용 stable DNS를 사용한다. data plane과 + Sentinel plane은 서로 다른 CA/leaf material을 가지며, peer 연결에 필요한 root만 명시적 + trust bundle로 교차 포함한다. +- ACL identity를 하나의 `redis-user`로 합치지 않는다. + - application data user: 선택 capability/program command/key/channel만; + - replica user: `+psync +replconf +ping`; + - Sentinel-to-data user: 공식 최소 Sentinel control command/channel set; + - Sentinel peer user: Sentinel 간 통신에 필요한 동일 superuser credential; + - application Sentinel discovery user: auth/hello/ping/role과 allowlisted read-only + `SENTINEL` subcommand만. + default user는 off이며 application/data/discovery user에 `+@all`, `allkeys`, + `allchannels`를 주지 않는다. +- Redis data ACL과 Sentinel ACL은 별도 template/projection이다. Sentinel peer superuser가 + data Redis에, data capability user가 Sentinel에 존재하면 static contract가 실패한다. +- Secret/CA/private key/rendered config는 run별 `umask 077` 아래 생성하고 tracked manifest에는 + Secret value, PEM, password가 없다. probe/command line에 `--pass`를 쓰지 않는다. +- exec probe를 사용해 kubelet source CIDR 예외를 만들지 않는다. default-deny ingress/egress + 뒤 data 6379, Sentinel 26379, kube-dns와 exact qualification/application pod selector만 + 허용한다. +- Service는 headless/ClusterIP만, PDB는 data/Sentinel 각각 `minAvailable: 2`, container는 + non-root, read-only root filesystem, privilege escalation false, capabilities drop ALL, + seccomp RuntimeDefault, explicit requests/limits를 요구한다. +- structural positive test와 한 필드씩 제거/변조한 mutation-negative fixture가 + anti-affinity, spread, PDB, probes, TLS-only, ACL separation, Secret reference, + NetworkPolicy, image SSOT를 실제로 fail시키는지 검증한다. +- `hostPath`, `hostNetwork`, `hostPID`, `hostIPC`, privileged, NodePort, LoadBalancer, + tracked Secret data/stringData/PEM과 implicit latest image를 거절한다. +- static validator는 exact document inventory, duplicate YAML key/identity, selector/template + 일치, exact NetworkPolicy edge graph를 검증한다. 정적 ordinal bootstrap은 최초 + `redis-data-0` primary와 두 replica만 증명하며, failover 뒤 old-primary 재합류와 stale + direct write 차단은 live gate에 남긴다. + +### Task 11.2B — Sentinel workload와 live security baseline GREEN + +- Redis primary 1 + replica 2와 Sentinel 3/quorum 2를 세 node에 분산한다. +- anti-affinity/topology spread, PDB, NetworkPolicy, separate data/Sentinel CA와 named ACL을 + 적용한다. +- secret/certificate/k3s token은 매 run `umask 077` transient material로 생성하고 tracked + manifest에는 값/PEM을 넣지 않는다. Sentinel bootstrap config는 Secret volume에서 pod별 + writable PVC로 최초 1회 atomic init-copy하며, 기존 rewritten config를 덮어쓰지 않는다. +- Redis image는 `redis.minimum.image` exact image/digest를 render하고 실제 pod image + ID/digest가 일치하는지 수집한다. +- data credential/CA로 Sentinel discovery가 실패하고 Sentinel material로 data command가 + 실패하는 negative test, untrusted CA/hostname mismatch/plaintext rejection을 실행한다. +- `SENTINEL CKQUORUM`, writable config rewrite/restart, exact 3 Ready placement, PDB, + default-deny/explicit-allow NetworkPolicy enforcement를 live k3s에서 검증한다. +- failover 중 죽어 있던 old primary가 재합류할 때 readiness가 stale direct write를 허용하지 + 않고 새 primary의 replica로 수렴하는지 live 검증한다. + +### Task 11.3 — Sentinel client runtime TDD + +- current `UnsupportedOperationException`을 먼저 고정하는 test를 quorum-consistent discovery와 + 분리된 discovery/data material contract로 교체한다. +- 2-of-3 Sentinel이 같은 primary를 보고할 때만 후보를 만들고 loopback/wildcard/unexpected + endpoint를 거절한다. +- active Sentinel role이 있을 때만 registry당 daemon worker 1개, role당 fixed-delay task 1개를 + 만들고 `sentinel-discovery-refresh-period`(기본 30초, 5초..5분)를 적용한다. +- scheduled poll과 command failure-triggered immediate rediscovery는 role별 같은 single-flight를 + 공유한다. `snapshot()`은 보조 trigger일 뿐 정상 polling을 대신하지 않는다. +- 정상 poll은 Sentinel material만 해석하고 현재 route identity와 같으면 data material/client를 + 만들지 않는다. 바뀐 quorum-approved endpoint에만 data candidate를 연다. +- command failure listener는 route lease 반환 뒤 topology/connectivity `UNAVAILABLE`에만 + 동작하며 listener 실패가 원래 certainty를 덮어쓰지 않는다. +- 새 data runtime은 version/program/semantic readiness를 통과한 뒤 router에 install한다. +- opaque route identity와 monotonic generation token으로 stale/same-primary candidate를 + 거절하고, install된 경우 old runtime은 new admission을 닫고 bounded drain/close한다. +- close는 task/worker를 bounded 종료하고 late candidate를 install하지 않고 정확히 한 번 닫는다. +- mutation을 자동 replay하지 않고 실행 여부가 불명확하면 `INDETERMINATE`를 보존한다. + +### Task 11.4 — Multi-pod normal/failover qualification + +1. host/lab preflight와 3 node/Sentinel quorum readiness를 수집한다. +2. 서로 다른 application pod에서 rate limit evaluation replay, idempotency + claim/start/renew/complete, session create/read/touch/rotate/revoke를 검증한다. +3. current primary pod를 kill하고 readiness unavailable timestamp를 기록한다. +4. Sentinel quorum election, client rediscovery, runtime generation swap/drain, semantic + readiness recovery를 실제 순서대로 기록한다. +5. election 60초, 추가 rediscovery/swap 30초, 총 recovery 90초의 regression limit을 적용한다. +6. rate state가 조용히 reset되지 않고 idempotency owner/terminal 결과가 중복되지 않으며 + confirmed session state가 유지되는지 확인한다. +7. old primary의 replica 재합류와 모든 actor의 동일 generation 관측을 확인한다. + +correctness role에는 bounded `min-replicas-to-write`/`min-replicas-max-lag`와 명시적 replica +acknowledgement policy를 사용한다. zero-data-loss/strong consistency를 주장하지 않으며 +response-only cut 등 실행 여부가 불확실한 mutation은 `INDETERMINATE`이고 blind retry하지 않는다. + +### Task 11.5 — Evidence와 exact teardown + +- actual image digest/image ID, config/program digest, sanitized fault/election/recovery timeline, + capability별 outcome/certainty, Kubernetes/Sentinel 관측을 allowlist schema로 생성한다. +- `NOT_CAPTURED` placeholder는 qualification 성공으로 인정하지 않는다. +- sanitizer/reconciler 성공 뒤에도 human clean commit/remote CI 전에는 + `releaseQualification=NOT_CLAIMED`를 유지한다. +- 성공/실패 모두 exact VM allowlist를 teardown하고 lab resource가 0인지 확인한다. local + retain-on-failure opt-in은 명시된 경우만 허용하고 CI에서는 금지한다. + +## Task 12 — CI, runbook, verification와 Wiki capture + +**CI** + +- PR blocking `redis-standalone` job을 `release-gate.needs`와 result loop에 실제 포함한다. +- nightly/release Redis production readiness workflow를 추가한다. +- workflow contract test로 blocking job/aggregator 집합 동등성을 검증한다. + +**Verification** + +```bash +cd src +./gradlew :application-core:redisPolicyContractTest --console=plain +./gradlew :shared-contract:edgeRateLimitContractTest --console=plain +./gradlew :adapter:outbound:cache-redis:check --console=plain +./gradlew :app-bootstrap:redisCompositionTest --console=plain +./gradlew redisProductionReadiness --console=plain +./gradlew test --console=plain +./gradlew check --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew verifyPublicPathSnapshot --console=plain +./gradlew verifyEnvKeys --console=plain +``` + +**Documentation** + +- capability별 실제 readiness와 남은 R3 한계를 README/spec/runbook에 동기화한다. +- 실행 명령, image/config/program digest, 실패/차단을 public LLM Wiki + `/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes/main.md`에 + 기록하고 실제 파생 오류/면접/블로그 raw 문서를 양방향 링크한다. + +**Completion gate** + +- Task 11의 exit gate를 통과하면 `Sentinel-first R2-ready candidate`라고만 보고한다. +- clean committed source와 실제 remote CI가 없으면 selected/R2로 승격하지 않는다. +- 이 milestone 보고 뒤 멈추고 Cluster/R3/fenced coordination 또는 fileserver/HTTP client 중 + 다음 작업을 사용자와 다시 정한다. + +## Task 13 — Resume blocker: selection-driven role activation과 default boot + +**Problem** + +- provider definition뿐 아니라 role binding도 capability가 선택되지 않으면 inert여야 한다. +- 현재 구현은 role binding 전체를 runtime으로 열고 health contributor도 role property 존재만으로 + 활성화한다. +- local 기본값에서 inbound rate-limit은 provider 없이 활성화되면 안 된다. + +**Tests first** + +- CACHE/COORDINATION/SESSION deployment와 role을 모두 사전 선언해도 cache/rate/idempotency/lease/ + session capability가 비활성이면 credential/trust resolution, native client, scheduler/subscriber, + Redis health contributor가 모두 0이다. +- 각 capability가 `redis`를 선택할 때만 해당 role이 활성화된다. +- 같은 role을 쓰는 coordination capability 둘 이상은 하나의 runtime만 공유한다. +- 선택 capability의 role binding이 빠지면 material resolution 전에 startup이 실패한다. +- shipped `.env`와 실제 `application.yml`은 transport disabled/provider disabled 조합으로 기동 + 가능하고 중복 legacy rate-limit block이 없다. + +**Implementation** + +- deployment/role registry validation과 runtime activation을 분리한다. +- `selectedCapabilities`가 비어 있는 role은 registry/router/health에서 제외한다. +- bootstrap health condition도 role property가 아니라 effective selected capability로 판단한다. +- provider 설정은 inert 후보로 남기되 선택된 capability의 잘못된 role은 fail closed 한다. + +## Task 14 — Resume blocker: capability-aware semantic readiness + +**Problem** + +- PING만으로 `AVAILABLE/PROBE_SUCCEEDED`를 선언하지 않는다. +- required coordination/session은 실제 선택 capability의 program ACL과 최소 read/write 계약이 + 동작해야 ready다. + +**Tests first** + +- PING은 성공하지만 `SCRIPT LOAD`/`EVALSHA`가 ACL로 거절된 coordination/session user는 + `redisRequired=DOWN`이다. +- capability별 representative program의 실제 key count와 command-to-key mapping을 그대로 + 검증한다. rate-limit의 state/dedup/order key와 session tombstone key 중 하나만 ACL pattern에서 + 빠져도 semantic readiness는 실패한다. +- Redis 7.2 미만 server는 metadata 표기만으로 통과하지 않고 bounded runtime handshake에서 + sanitized unsupported-version 상태가 된다. +- 대표 program과 ACL probe script가 이미 warm인 상태에서도 runtime user의 `SCRIPT LOAD` + 권한 누락을 별도로 탐지한다. +- cache optional role에서 semantic probe 실패는 application liveness/readiness를 내리지 않고 + `DEGRADED`만 보고한다. +- 선언된 optional cache가 cold-start connect/PING에 일시 실패해도 context는 bounded unavailable + route로 시작하고, health-triggered bounded single-flight reconnect 뒤 재시작 없이 복구한다. + invalid configuration/material/program/schema는 계속 startup failure이며 required + coordination/session은 fail closed다. +- probe는 raw key/value, credential, server exception을 health detail에 노출하지 않는다. +- probe key는 bounded, namespaced, TTL이 있고 성공/실패 후 잔여 상태가 없다. +- saturation/recent command failure/closed route를 distinct sanitized reason으로 분류한다. +- health scrape는 role별 minimum cadence와 single-flight로 full semantic suite 실행을 제한하고, + cached observation의 시각/age를 노출해 stale success를 숨기지 않는다. + +**Implementation** + +- role별 선택 capability를 입력으로 immutable semantic probe plan을 만든다. +- probe는 catalog-owned bounded program과 capability-safe ephemeral operation만 사용한다. +- optional cold-start outage는 resource-free unavailable runtime과 bounded on-demand reconnect로 + 표현하며 별도 unbounded scheduler/thread를 만들지 않는다. L1 invalidation subscription은 + route recovery 시 실제 runtime에 다시 연결된다. +- eviction은 runtime `CONFIG` 권한을 열지 않고 `CONFIGURED_EXPECTATION_ONLY`로 유지하며 외부 + attestation 미완료를 readiness detail에 명시한다. + +## Task 15 — Resume blocker: bounded common primitive catalog + +**Problem** + +- Deep design §14.6–§14.9의 자주 쓰는 race-safe helper가 아직 compare/delete 중심 R0 foundation에 + 머물러 있다. + +**Tests first** + +- String, counter, hash, set, sorted-set, list baseline은 typed/versioned key, value/count/byte/deadline, + role, slot, TTL, certainty bound를 강제한다. +- bitmap/HLL/geo는 billing/auth correctness에 사용할 수 없는 explicit semantic classification과 + offset/result/fan-in bound를 강제한다. +- `INCR -> EXPIRE`, set/list admission, revision-CAS는 실제 Redis concurrency에서 atomic하다. +- unbounded `HGETALL`, `SMEMBERS`, `LRANGE`, arbitrary command/script surface는 제공하지 않는다. + +**Implementation** + +- package-private `RedisPrimitiveCatalog`과 structure별 bounded facade를 Redis leaf 내부에 둔다. +- application/shared public API에는 Redis command나 raw key를 노출하지 않는다. +- 아직 실제 semantic consumer가 없는 primitive는 Spring bean/public capability로 노출하지 않는다. + +## Task 16 — Resume blocker: capability observability와 graceful lifecycle + +**Tests first** + +- cache/rate/idempotency/lease/session의 operation, outcome, certainty, role, queue/latency가 bounded + low-cardinality metric/event로 관측된다. +- raw key, subject, session/idempotency/lease token, secret reference/value, exception message는 + tag/log/trace에 들어가지 않는다. +- optional cache와 required coordination/session의 failure signal이 health와 metric에서 일치한다. +- shutdown은 subscriber/scheduler/router/runtime 순서로 bounded drain되고 새 command를 거절한다. + +**Implementation** + +- framework-neutral observation event/port와 Micrometer rendering을 계층 소유권에 맞게 둔다. +- trace/log는 기존 skeleton observability 경계를 재사용하고 Redis native type을 core에 유출하지 + 않는다. +- `docs/registries/metrics.yaml`과 runbook을 실제 emitted metric과 동기화한다. + +## Task 17 — Resume final review, readiness truth, verification와 Wiki + +- Task 13–16을 task별 spec/code-quality review한다. +- Redis deep design §39/§40을 독립 재검토해 selected/implemented-candidate/not-implemented를 실제 + evidence와 일치시킨다. +- Sentinel/Cluster/k3s/R3 evidence가 없으면 지원/완료로 표기하지 않는다. +- Task 12의 전체 검증을 실행하고 동시 작업의 비-Redis 실패는 소유 파일과 증거를 분리한다. +- Redis README/spec/runbook, readiness registry, CI artifact 계약을 동기화한다. +- LLM Wiki branch-note와 실제 파생 raw 문서를 양방향 링크로 캡처한다. diff --git a/docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md b/docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md new file mode 100644 index 0000000..1021b1a --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-redis-lab-strict-kubeconfig-renderer.md @@ -0,0 +1,241 @@ +# Redis Lab Strict Kubeconfig Renderer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox +> (`- [ ]`) syntax for tracking. + +**Goal:** Complete parent Task 11.1A by replacing mutation-by-mutation kubeconfig filtering with a +pinned-K3s, strict block-grammar validator/renderer and passing an independent safety review. + +**Architecture:** Freeze the already-reviewed lifecycle/ownership state machine as Task 11.1A-1. +Move kubeconfig validation/rendering into one tracked AWK program, Task 11.1A-2. The program accepts +only the exact single-cluster/context/user block grammar emitted by the pinned K3s slice, transforms +only lab identity fields, and rejects every non-allowlisted structure before any lab `kubectl` +command. + +**Tech Stack:** Bash 5 strict mode, POSIX-compatible AWK features already used by the repository, +the fake-command shell contract, Gradle 9, Java 21. + +## Global Constraints + +- Do not create a VM, run real Multipass/k3s/kubectl, inspect host inventory, or access the network. +- Do not modify Task 11.1A-1 ownership, state, signal, lock, cleanup or fingerprint behavior. +- Do not add `yq`, PyYAML, Ruby, Java YAML runtime, or another downloadable parser dependency. +- The only accepted source grammar is the pinned K3s admin kubeconfig block-style shape defined in + deep design §37.13.4.1. +- `preferences: {}` is the only permitted flow collection. +- Validation failure removes the destination, emits only `redis-lab: lab kubeconfig invalid`, and + occurs before lab `kubectl`. +- Preserve prior `CREATED|RECONCILE` state and delete only exact marker-proven current-run VMs. +- Tests must show RED against the current implementation before production changes. +- Human-only Git policy applies: do not stage, commit, amend or push. + +--- + +### Task 1: Extract a strict generated-kubeconfig renderer + +**Files:** + +- Create: `infra/redis-lab/lib/render-kubeconfig.awk` +- Modify: `infra/redis-lab/bin/redis-lab` +- Modify: `infra/redis-lab/test/redis-lab-contract.sh` + +**Interfaces:** + +- Consumes: `awk -v address= -v target=ca-redis-lab -f `. +- Produces: rendered kubeconfig on stdout and exit `0`, or no accepted output and non-zero exit. +- Integration: `render_lab_kubeconfig ` performs atomic + temporary render, mode `0600`, destination replacement only after renderer success. + +- [x] **Step 1: Add realistic positive and sibling-flow RED fixtures** + + Change the fake `valid` kubeconfig to this complete credential-data shape, using canary values + rather than real certificate material: + + ```yaml + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: preserve-default-ca-canary + server: https://127.0.0.1:6443 + name: default + contexts: + - context: + cluster: default + namespace: team-default + user: default + name: default + current-context: default + kind: Config + preferences: {} + users: + - name: default + user: + client-certificate-data: preserve-default-client-cert-canary + client-key-data: preserve-default-client-key-canary + ``` + + Add separate public `up` variants containing, after their canonical item: + + ```yaml + cluster : {server: https://foreign.invalid:6443} + ``` + + and: + + ```yaml + context : {cluster: foreign, user: foreign} + ``` + + Each variant must assert failure, zero lab `kubectl`, three exact marker-proven deletes, removed + rendered kubeconfig, and no forbidden fake invocation. + +- [x] **Step 2: Run the direct contract and verify RED** + + Run: + + ```bash + bash -n infra/redis-lab/bin/redis-lab infra/redis-lab/test/redis-lab-contract.sh + bash infra/redis-lab/test/redis-lab-contract.sh + ``` + + Expected: syntax succeeds and the first new sibling-flow case fails because the current renderer + unexpectedly accepts it. + +- [x] **Step 3: Implement the strict AWK state machine** + + `render-kubeconfig.awk` must use an explicit `state` transition for every accepted line. It must + not print from a catch-all rule. The accepted transition sequence is: + + ```text + apiVersion -> clusters -> cluster-item -> ca-data -> server -> cluster-name + -> contexts -> context-item -> context-cluster -> optional-namespace -> context-user + -> context-name -> current-context -> kind -> preferences -> users -> user-name + -> user-body -> client-cert -> client-key -> EOF + ``` + + Exact identity transitions print these replacements: + + ```awk + print " server: https://" address ":6443" + print " name: " target + print " cluster: " target + print " user: " target + print "current-context: " target + print "- name: " target + ``` + + CA/client credential and namespace transitions print `$0` unchanged. Any unmatched line sets + `invalid=1`; `END` exits non-zero unless the final state is `client-key`, every required + transition occurred once, the input had no tab/CR/YAML marker, and no trailing line exists. + +- [x] **Step 4: Integrate the renderer fail-closed** + + Add: + + ```bash + KUBECONFIG_RENDERER="${REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" + ``` + + `validate_static_contract` must require a readable regular non-symlink renderer at that exact + canonical path. Replace the inline AWK body with: + + ```bash + local render_next="${destination_file}.next" + rm -f -- "${render_next}" + if ! awk -v address="${server_address}" -v target="${CONTEXT_NAME}" \ + -f "${KUBECONFIG_RENDERER}" "${source_file}" >"${render_next}"; then + rm -f -- "${render_next}" "${destination_file}" + fail 'lab kubeconfig invalid' + return 1 + fi + chmod 0600 -- "${render_next}" + mv -f -- "${render_next}" "${destination_file}" + ``` + + Add the `.next` destination to symlink-child validation. Propagate `rm`, `chmod` and `mv` + failures with the same sanitized error and without retaining a partially accepted destination. + +- [x] **Step 5: Run focused GREEN** + + Run the direct contract again. Expected: `redis-lab-contract: PASS`, exit `0`. + +### Task 2: Complete the mutation matrix and parent acceptance + +**Files:** + +- Modify: `infra/redis-lab/test/redis-lab-contract.sh` +- Modify: `infra/redis-lab/README.md` +- Modify: `docs/superpowers/plans/2026-07-29-redis-production-capability-completion.md` +- Modify: + `.superpowers/sdd/2026-07-29-redis-production-capability-completion/progress.md` +- Create: + `.superpowers/sdd/2026-07-29-redis-production-capability-completion/task-11-1a-2-brief.md` +- Create: + `.superpowers/sdd/2026-07-29-redis-production-capability-completion/task-11-1a-2-report.md` + +**Interfaces:** + +- Consumes: Task 1 strict renderer and existing lifecycle fake runtime. +- Produces: parent Task 11.1A review package with no open Critical/Important finding. + +- [x] **Step 1: Add one mutation per grammar boundary** + + Add table-driven fixture variants for missing, duplicate, reordered and unknown keys; whitespace + before colon; quoted/tagged/explicit keys; anchor/alias/merge; unexpected `{}`/`[]`; tab, CRLF, + `---`/`...`, and trailing content. Every case must assert failure before lab `kubectl`, exact + current-run cleanup and removed render output. + +- [x] **Step 2: Prove scalar preservation and exact transformation** + + The positive case must assert: + + ```text + server: https://192.0.2.10:6443 + name/current-context: ca-redis-lab + namespace: team-default + preserve-default-ca-canary + preserve-default-client-cert-canary + preserve-default-client-key-canary + ``` + + It must also assert that no `name: default`, `cluster: default`, `user: default`, + `current-context: default` or loopback server remains. + +- [x] **Step 3: Re-run the full fake-only verification** + + Run: + + ```bash + bash -n infra/redis-lab/bin/redis-lab infra/redis-lab/test/redis-lab-contract.sh + bash infra/redis-lab/test/redis-lab-contract.sh + cd src + ./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain + ./gradlew :adapter:outbound:cache-redis:test --console=plain + ./gradlew :adapter:outbound:cache-redis:check --dry-run --console=plain + ``` + + Expected: direct `PASS`; both Gradle executions `BUILD SUCCESSFUL`; dry-run includes + `redisLabContractTest`. + +- [x] **Step 4: Run an independent scoped review** + + Reviewer acceptance: + + - strict renderer has no catch-all pass-through; + - the valid pinned fixture reaches EOF exactly once; + - every non-allowlisted structural line fails; + - destination publication is atomic/fail-closed; + - Task 11.1A-1 lifecycle code is unchanged except the renderer call and static path checks; + - Critical `0`, Important `0`, both spec and quality PASS. + +- [x] **Step 5: Close the parent task** + + Only after Step 4 passes, replace the ledger `BLOCKED` state with an additive resolution line: + + ```text + Task 11.1A-2: complete (human-only commit policy; strict renderer review clean) + Task 11.1A: complete (11.1A-1 lifecycle + 11.1A-2 renderer; fake-only evidence) + ``` + + Do not claim live readiness, R2 or VM/k3s qualification. diff --git a/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md b/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md index a31405d..fc0141c 100644 --- a/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md +++ b/docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md @@ -1,12 +1,14 @@ # Fileserver Production Capability Deep Design - 작성일: 2026-07-26 -- 상태: 상세 설계 완료, Phase 0–1 및 Phase 2 일부 local R1 구현, R2 이상 미구현 +- 상태: 상세 설계 완료, Phase 0–1 및 Phase 2 `local-persistent` R2 구현, 후속 provider/운영 + capability 미구현 - 독립 아키텍처 재리뷰: blocker/high 0건 - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 대상 leaf: `adapter-outbound-fileserver` -- 구현 추적: 이 문서의 목표 전체가 아니라 framework-free port, local staged CSV, single-node - operation journal/recovery까지만 적용되었다. +- 구현 추적: 이 문서의 장기 목표 전체가 아니라 provider-neutral application/control 계약, + exact selector, pre-provisioned local filesystem을 위한 `local-persistent` R2 provider까지만 + 적용되었다. - 상위 문서: [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) @@ -26,24 +28,45 @@ - operation-scoped JVM/OS file lock과 hard-link-only publication protocol; - overwrite-capable legacy port의 별도 opt-in/root 및 canonical overlap 차단; - 안전한 commit primitive가 없을 때 copy-to-final로 downgrade하지 않는 fail-closed 동작. +- `app.fileserver` exact destination/provider selector와 producer 호출 전 unknown destination + 거부; +- provider ID별 singleton runtime과 서로 다른 provider ID의 동일 normalized root 소유 거부; +- provider-neutral canonical operation v2/private manifest/reference index와 opaque + `fsr1...` direct lookup; +- strict UTF-8/canonical schema-v1 terminal record의 read-only compatibility와 schema-v2-only + write; +- absolute/pre-provisioned root, ancestor/root symlink, real path, owner/mode, FileStore + name/type, mount sentinel, `SecureDirectoryStream`, exclusive-create/hard-link/file·directory + force startup attestation; +- `WRITING -> SEALED -> DATA_PUBLISHED -> MANIFEST_PUBLISHED -> REFERENCE_PUBLISHED -> + PUBLISHED` durable publication ordering; +- data/manifest/reference/receipt 전체 교차검증과 deterministic resume/quarantine; +- terminal mismatch에서 journal과 모든 artifact를 불변 보존하는 fail-closed recovery; +- `FILE_AND_DIRECTORY_SYNC` receipt와 forked-process force-boundary/OS operation-lock + qualification seam; +- `app-bootstrap` opt-in composition과 disabled-default/no-filesystem-side-effect gating. 아직 구현되지 않은 범위: -- Phase 2의 cross-node fencing, reference/private-manifest index, exhaustive crash/symlink-race - qualification; -- 운영 cleanup/quota/retention과 effective capability probe인 Phase 3; +- `shared-mounted`/NFS multi-client semantics와 cross-node producer fencing; +- 운영 background reconciliation/reaper, retention, quota/backpressure인 Phase 3; +- Fileserver 전용 readiness/health, metrics, tracing, structured audit; - SFTP provider인 Phase 4; -- NFS/HA/bootstrap evidence인 Phase 5; +- NFS/HA/operator topology evidence인 Phase 5; - optional delete/read/scan operation인 Phase 6. -따라서 현재 journal은 single-node local recovery seam이며 Fileserver R2 완료 증거가 아니다. -기존 `FileExportPort`도 호환성을 위해 +따라서 현재 R2 claim은 `local-persistent`에만 한정한다. `FILE_AND_DIRECTORY_SYNC`는 attested +filesystem 안에서 file과 관련 directory force가 성공했다는 뜻이며 physical device, +storage-controller cache, volume replica, backup/site의 power-loss protection을 뜻하지 않는다. +그 축은 deployment/storage evidence가 별도로 소유한다. 기존 `FileExportPort`도 호환성을 위해 남아 있으며, 전체 행 materialization과 absolute path receipt를 사용하는 legacy 경로다. +기존 R1 terminal artifact는 strict read-only로 원래 `PROCESS_LOCAL_SYNC` receipt만 복원하고 +manifest/reference 생성, schema-v2 rewrite, R2 guarantee 자동 승격을 하지 않는다. ## 1. 설계 판정 -현재 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의 로컬 -CSV 예제다. +설계 시작 당시 Fileserver 구현은 운영 파일서버가 아니라 다음 한 경로만 제공하는 R1 이하의 +로컬 CSV 예제였다. 현재의 increment 상태와 보장 경계는 §0을 따른다. ```text List> @@ -109,7 +132,11 @@ List> 이번 문서는 위 항목을 구현 계획을 작성할 수 있는 수준까지 확정한다. -## 3. 현재 코드의 증거 기반 진단 +## 3. 초기 코드의 증거 기반 진단 + +아래 표는 설계가 시작된 2026-07-26의 baseline을 보존한 역사적 진단이다. 현재 구현 상태는 +§0이 권위이며, 아래 결함 중 streaming/opaque receipt/exclusive publication/control plane/local +attestation/composition은 후속 increment에서 해소되었다. | 영역 | 현재 구현 | 운영상 의미 | | --- | --- | --- | @@ -134,13 +161,14 @@ List> - `src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java` - `src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java` - `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java` -- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java` +- `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java` - `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java` - `src/config/architecture/modules.json` - `src/app-bootstrap/build.gradle` -현재 7개 fileserver unit test와 leaf `check`는 성공한다. 이는 현재 문서화된 로컬 happy-path -계약이 동작한다는 증거일 뿐 production readiness 증거는 아니다. +당시 7개 fileserver unit test와 leaf `check` 성공은 로컬 happy-path만 증명했다. 현재의 +`local-persistent` claim은 별도 root attestation, control/payload/recovery, forked crash와 +cross-process OS lock qualification suite의 통과를 요구한다. ## 4. 범위와 명시적 비범위 @@ -1927,22 +1955,26 @@ ca-skeleton: `docs/registries/env-keys.yaml`, `application.yml`, typed settings, conditional beans를 end-to-end 검증한다. -Template baseline에 필요한 key 예: +현재 구현된 `local-persistent` composition에 등록하는 key: ```text -APP_FILESERVER_PRIMARY_ROOT -APP_FILESERVER_PRIMARY_MOUNT_ID -APP_FILESERVER_SFTP_HOST -APP_FILESERVER_SFTP_USERNAME -APP_FILESERVER_SFTP_PRIVATE_KEY_SECRET_REF -APP_FILESERVER_SFTP_KNOWN_HOSTS_SECRET_REF -APP_FILESERVER_SFTP_CONTROL_ROOT -APP_FILESERVER_SFTP_SPOOL_ROOT -APP_FILESERVER_SECRET_CONFIG_ROOT +APP_FILESERVER_ENABLED +APP_FILESERVER_LOCAL_ROOT +APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME +APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE +APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256 +APP_FILESERVER_LOCAL_EXPECTED_OWNER ``` +모두 restart-only다. `APP_FILESERVER_ENABLED=false`가 shipped default이며, 나머지 다섯 +attestation 값은 `app.fileserver.enabled=true`일 때 모두 필요하다. Root는 absolute/existing +directory, FileStore name/type과 owner는 non-blank exact match, sentinel digest는 64-character +lowercase SHA-256여야 한다. + Dynamic destination topology는 YAML/config tree가 소유하고 secret value는 secret source가 -제공한다. +제공한다. 앞의 broader topology 예시에 있는 SFTP/NFS key는 아직 env registry나 shipped +`application.yml`에 등록하지 않는다. 실제 provider, dependency, real-service qualification이 +추가되는 후속 increment에서만 등록한다. ## 24. Health와 observability @@ -2228,9 +2260,10 @@ Nightly: ### 27.1 Dependency ownership -현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK, external dependency -없음, NFS/SFTP stand-in만을 허용한다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수 -없다. 구현 Phase 0에서 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다. +현재 가장 가까운 `src/adapter/outbound/fileserver/CLAUDE.md`는 pure JDK filesystem과 Spring +configuration baseline만 허용하고 `local-persistent`만 구현 대상으로 인정한다. NFS/SFTP +stand-in이나 SDK는 허용하지 않는다. 따라서 이 문서만으로 SFTP SDK를 Gradle에 추가할 수 없다. +후속 SFTP 구현에서는 아키텍처 승인과 함께 다음 rule drift를 먼저 갱신한다. - local `CLAUDE.md`의 책임을 local-only demo에서 provider-based publication으로 변경; - external `NONE` 규칙을 exact allowlist로 변경; @@ -2240,7 +2273,7 @@ Nightly: 이 rule migration 전 SFTP dependency 추가나 runtime activation은 HARD-STOP이다. -`adapter-outbound-fileserver`: +후속 provider rule migration의 후보 allowlist이며 현재 dependency가 아니다: - JDK NIO local/mounted provider; - Spring autoconfigure; @@ -2260,7 +2293,8 @@ starter를 추가하지 않는다. ### 27.2 Bootstrap composition -안전한 explicit binding/gating과 config test가 먼저 구현된 후: +`local-persistent`에 대한 안전한 explicit binding/gating과 config test가 구현되었고 다음 +composition을 적용했다. 1. `modules.json`의 `app-bootstrap.allowed_dependencies`에 `adapter-outbound-fileserver` 추가; @@ -2271,7 +2305,8 @@ starter를 추가하지 않는다. 6. disabled-adapter architecture scan에 fileserver 추가; 7. env/settings/readiness contract 추가. -Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된다. +`application.yml`의 `app.fileserver.enabled=false`가 shipped default다. Classpath에 들어왔다는 +이유만으로 local provider가 활성화되거나 filesystem side effect가 발생하지 않는다. ### 27.3 SDK split trigger @@ -2287,9 +2322,11 @@ Classpath에 들어왔다는 이유로 local provider가 활성화되면 안 된 ### Phase 0 — Truthful topology와 contract freeze -- 현재 Fileserver를 R1 local CSV demo로 명시; +상태: 완료. 현재 문서는 provider별 구현 상태와 보장 경계를 분리한다. + +- 초기 Fileserver를 R1 local CSV demo로 명시하고 후속 R2 범위를 분리; - Fileserver `CLAUDE.md`와 README의 responsibility/dependency/registry SSOT drift 수정; -- current bootstrap 미합성 상태 명시; +- 초기 bootstrap 미합성 상태와 후속 disabled-default opt-in composition을 함께 기록; - v2 contract와 error registry 승인; - journal/reference/control-plane schema 승인; - accepted-attempt와 global coordination guarantee 분리; @@ -2306,6 +2343,8 @@ Acceptance: ### Phase 1 — Streaming application contract와 CSV +상태: 완료. Framework-free `FilePublicationPort`와 bounded streaming CSV 경로가 구현되었다. + - `FilePublicationPort`; - operation ID/fingerprint; - effective policy snapshot; @@ -2321,6 +2360,9 @@ Acceptance: ### Phase 2 — Secure local/mounted publication +상태: `local-persistent` 완료. `shared-mounted`/NFS multi-client profile과 cross-node fencing은 +미구현이다. + - staging; - digest/manifest; - sealed journal과 protocol별 artifact ordering; @@ -2336,6 +2378,8 @@ Acceptance: ### Phase 3 — Resource/maintenance/observability +상태: 미구현. + - concurrency/byte quota; - timeout/cancel/shutdown; - staging reaper/report; @@ -2348,6 +2392,8 @@ Acceptance: ### Phase 4 — SFTP provider +상태: 미구현. SFTP setting/env/dependency/bean도 등록하지 않는다. + - Spring Integration/Apache MINA; - host key/secrets; - bounded pool/timeouts; @@ -2362,6 +2408,9 @@ Acceptance: ### Phase 5 — NFS/HA evidence와 bootstrap +상태: `app-bootstrap`의 disabled-default opt-in composition과 local env mapping만 완료. +NFS/HA/operator topology evidence는 미구현이다. + - multi-client NFS profile; - operator attestation; - app-bootstrap composition; @@ -2374,6 +2423,8 @@ Acceptance: ### Phase 6 — Optional read/delete와 module split review +상태: 미구현. + - opaque content transfer; - expected-version managed delete; - provider split 조건 재평가; @@ -2381,6 +2432,9 @@ Acceptance: ## 29. 완료 기준 +아래는 이 장기 설계 전체의 완료 기준이며 현재 충족되지 않았다. 현재 완료 claim은 §0의 +`local-persistent` R2 범위로 제한한다. + Fileserver R2 완료를 주장하려면: - application contract에 path/provider/SDK가 없음; diff --git a/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md b/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md index 6a7364f..2669121 100644 --- a/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md +++ b/docs/superpowers/specs/2026-07-26-production-capability-platform-design.md @@ -949,6 +949,13 @@ and durable interfaces are explicit. ### 13.3 Object storage +The authoritative implementation-level design for this capability is +[Object Storage Production Capability Deep Design](2026-07-28-objectstorage-production-capability-design.md). +Its ordered RED–GREEN execution batches and promotion gates are in the +[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md). +This subsection is only the cross-capability baseline; the dedicated design governs when details +differ. + Replace whole-object `byte[]` as the only path with: - streaming upload/download and range reads; diff --git a/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md b/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md index 9e3e1de..bdf2b58 100644 --- a/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md +++ b/docs/superpowers/specs/2026-07-26-redis-production-capability-design.md @@ -1,7 +1,7 @@ # Redis Production Capability Deep Design - Date: 2026-07-26 -- Status: 상세 설계 완료, Phase 0 및 Phase 1 일부 standalone R1 구현, R2 미구현 +- Status: 상세 설계 완료, 5개 standalone `implemented-candidate`, selected/R2 없음 - Scope: Redis 전용 production capability와 단계적 구현 설계 - Baseline: Java 21, Spring Boot 4.0.0, Gradle multi-module Clean Architecture template - Parent: @@ -9,7 +9,7 @@ ## 0. 구현 상태 -2026-07-28 기준 구현된 범위: +2026-07-30 기준 구현된 범위: - `application-core`의 provider-neutral `CacheRegionPort`와 hit/negative/miss/schema/unavailable 결과 구분; @@ -21,9 +21,17 @@ - generic application API가 아닌 package-private `RedisAtomicPrimitives` internal R0 foundation과 compatibility failure; - managed Lettuce standalone connection lifecycle과 finite command timeout; -- `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 `EVAL` fallback하는 production executor; +- `EVALSHA` 우선, 정확한 `NOSCRIPT`에만 catalog script를 `SCRIPT LOAD`하고 digest를 검증한 뒤 + `EVALSHA`를 한 번 재시도하는 production executor; - versioned digest-protected bounded binary cache envelope, positive/negative TTL, invalidate와 corrupt/future/unavailable 구분을 제공하는 `CacheRegionPort` reference adapter; +- envelope v2의 absolute soft/hard expiry, injected clock freshness 판정, deterministic + policy-revision/key jitter, hard minimum과 physical Redis TTL 일치; +- framework-free `CacheAsideExecutor`와 typed source/result/cancellation contract; +- maximum in-flight key/waiter/source concurrency/admission/load deadline을 제한하는 local + single-flight와 source bulkhead, abandoned-flight opportunistic reaping; +- authoritative absence만 negative-cache하고 classified transient failure에만 hard-expiry 전 + stale fallback을 허용하는 application policy; - HMAC key secret/namespace/value bound typed settings와 disabled zero-connection composition; - `managed`/`external` client mode를 통한 결정적 runtime 선택; - reconnect command replay 차단, finite Lettuce request queue와 client-side admission; @@ -32,27 +40,44 @@ - managed runtime 활성화 시 Redis host 누락을 `localhost`로 숨기지 않는 startup fail-fast; - generic Lua executor/descriptor와 raw-key typed primitive를 package-private collaborator로 닫고 Spring composition에는 semantic cache port만 노출; -- 명시적 Redis 7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua, - oversized bulk-reply 차단 검증. +- 명시적 Redis 7.2/7.4 standalone service lane의 실제 TTL expiry, compare-delete Lua, + oversized bulk-reply 차단 검증; +- `shared-contract`의 provider-neutral edge rate-limit request/policy/decision/outcome/port; +- fixed window, sliding-window counter, token bucket의 versioned one-key Lua와 bounded + structured MULTI reply parser; +- private HMAC key, Redis server time, clock regression clamp, denial-no-consume, finite state + TTL과 pre-send/post-dispatch failure certainty를 보존하는 semantic provider; +- cache와 endpoint/connection/admission/settings를 공유하지 않는 coordination-role 전용 + `app.rate-limit` composition과 disabled zero-side-effect gating; +- 세 알고리즘을 실제 standalone Redis에 실행하도록 선택 가능한 service qualification lane; +- request-replay idempotency, cache refresh soft lease, versioned session repository semantic + provider와 각 card-owned standalone/security/fault/compatibility evidence; +- cache generation/revision invalidation, bounded local L1, authenticated invalidation hint, + semantic health/metrics와 standalone TLS+named ACL evidence. 아직 구현되지 않은 범위: -- cache jitter, soft/hard TTL, cache-aside/single-flight/source bulkhead; +- refresh-ahead와 probabilistic early refresh; - Redis Functions 배포와 program upgrade/rollback compatibility matrix; -- health/metrics/TLS/ACL/secret/topology/eviction 검증; -- distributed rate limit, idempotency, lease/fencing, session; +- Sentinel runtime, Cluster production qualification, k3s/multi-node/failover/rotation, + effective eviction/persistence attestation; +- fenced coordination과 multi-process/pod session 및 L1/L2 distributed qualification; - Phase 1의 전체 acceptance와 R2/R3 승격 증거. -따라서 standalone runtime/string cache는 R1 evidence를 가지지만 Redis capability 전체 또는 -어떤 production topology도 R2가 아니다. raw-key Lua foundation과 -rate/idempotency/lease/session은 semantic composition이 없어 여전히 R0다. +현재 registry의 cache, edge rate limit, request-replay idempotency, cache refresh soft lease, +session card는 standalone promotion topology의 `implemented-candidate`다. fenced coordination만 +`not-implemented`다. `implemented-candidate`는 구현과 card-owned evidence lane을 뜻할 뿐 release +selection이나 R2 qualification이 아니다. checked-in `selected` card가 0개이므로 Redis capability +전체 또는 어떤 production topology에도 R2 release claim을 하지 않는다. ## 1. 설계 판정 설계 착수 당시 `adapter:outbound:cache-redis`는 실제 Redis client, connection, topology, TTL, -codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-28 구현으로 -standalone managed Lettuce runtime과 semantic string cache는 R1까지 올라왔지만, topology, -TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-ready adapter는 아니다. +codec, atomic program, failure semantics가 없는 R0 extension seam이었다. 2026-07-30 현재 위 5개 +semantic provider는 standalone `implemented-candidate`이며 standalone TLS+named ACL과 bounded +fault evidence도 있다. 그러나 selection, Sentinel/Cluster, multi-node/failover/rotation, +effective eviction/persistence attestation과 R3 증거가 없으므로 production-ready/R2라는 단일 +label을 붙이지 않는다. 이번 설계는 다음 구조를 선택한다. @@ -75,16 +100,16 @@ TLS/ACL, restart/fault/eviction evidence가 없으므로 여전히 production-re | Capability | 현재 | 목표 | | --- | --- | --- | -| Redis runtime | managed Lettuce standalone R1 + explicit external-client mode | Spring Data Redis + Lettuce 기반 typed runtime | -| Cache | `Optional get`, `void put` | typed region, TTL, negative/stale, invalidate, cache-aside | -| Rate limit | inbound-web single-node fixed window | policy별 fixed/sliding/token/GCRA Redis provider | -| Idempotency | JPA 전제, owner token 없음 | atomic claim, owner-safe complete, execution/replay TTL 분리 | -| Lock | JDBC efficiency lock | Redis efficiency lease + 별도 fenced contract | -| Session | JWT stateless 고정 | JWT 또는 isolated Redis Session의 명시적 profile | -| Atomic helper | 없음 | versioned Function/Lua program registry | -| Topology | 없음 | standalone, Sentinel, Cluster의 typed exclusive profile | -| Failure | 모든 cache exception을 miss로 변환 | capability별 fail-open/closed/degraded/indeterminate | -| CI | fake unit test | real Redis, topology, concurrency, failure, compatibility matrix | +| Redis runtime | canonical role router와 managed/external Lettuce runtime, standalone candidate | Sentinel runtime과 Cluster production qualification | +| Cache | standalone `implemented-candidate`; generation/soft lease/bounded L1과 TLS/ACL/fault lane | multi-process L1/L2와 HA/persistence/eviction attestation | +| Rate limit | fixed/sliding-counter/token-bucket standalone `implemented-candidate` | HA topology, failover와 R3 evidence | +| Idempotency | owner-safe Redis V2 standalone `implemented-candidate`; JDBC provider와 명시적 선택 | actual-used image/event evidence와 selected promotion | +| Lock | Redis efficiency lease candidate; fenced coordination은 `not-implemented` | protected-resource stale fencing-token rejection | +| Session | JWT와 isolated Redis Session profile; Redis는 standalone `implemented-candidate` | multi-process/pod와 failover/rotation qualification | +| Atomic helper | versioned closed Lua catalog와 typed internal facade | Redis Functions upgrade/rollback matrix | +| Topology | standalone candidate; Cluster code seam; Sentinel runtime 미구현 | Sentinel/Cluster/k3s multi-node qualification | +| Failure | capability별 typed degraded/unavailable/indeterminate와 bounded fault lane | 실제 topology event chain과 persistence/restart evidence | +| CI | strict registry matrix, real candidate lanes, sanitized artifact/reconciler | actual-used image attestation과 actual fault-event capture | 설계가 완료되었다는 뜻은 구현 계약과 단계가 결정되었다는 뜻이다. 현재 Redis runtime이 production-ready가 되었다는 뜻은 아니다. @@ -145,7 +170,11 @@ production-ready가 되었다는 뜻은 아니다. 표의 링크 대상보다 예시 YAML이나 migration alias가 우선하지 않는다. 상충하는 두 설정이 존재하면 임의 precedence를 선택하지 않고 startup을 실패시킨다. -## 3. 증거 기반 현재 상태 +## 3. 설계 착수 당시 증거 기반 baseline + +이 절 전체는 구현 전 repository를 조사한 2026-07-26 역사적 baseline이다. 아래의 “현재”는 그 +조사 시점을 가리키며 2026-07-30 구현 상태를 설명하지 않는다. 최신 구현/readiness truth는 §0, +§1의 현재 열, checked-in `src/config/redis/readiness-cards.yaml`, Redis leaf README를 따른다. ### 3.1 실제 Redis client가 없다 @@ -5904,8 +5933,8 @@ indexed repository는 Cluster/node-specific event와 orphan index cleanup을 별 최소 실제 topology: - primary; -- replica; -- independent Sentinel quorum. +- replica 2개; +- 서로 다른 k3s node에 배치한 Sentinel 3개와 quorum 2. test: @@ -5921,6 +5950,260 @@ test: 단일 fake Sentinel endpoint로 HA를 증명하지 않는다. +#### 37.13.1 Sentinel discovery와 data runtime 분리 + +Sentinel discovery channel과 Redis data-node channel은 같은 Lettuce client/SSL context로 +합치지 않는다. 각각 독립된 named material과 lifecycle을 갖는다. + +| Channel | 책임 | 허용 material | +| --- | --- | --- | +| Sentinel discovery | master name 조회와 quorum 관측 | Sentinel ACL username/password reference, Sentinel CA/trust, discovery timeout | +| Redis data | capability command/program 실행 | data-node ACL username/password reference, data CA/trust, command/admission/drain timeout | + +discovery는 다음 조건을 모두 만족할 때만 새 primary 후보를 반환한다. + +- 구성된 Sentinel endpoint 최소 3개 중 2개 이상이 같은 master host/port를 보고한다; +- 응답한 Sentinel 수와 동의 수가 각각 bounded deadline 안에서 기록된다; +- master name이 exact configured name과 같다; +- 반환 endpoint가 loopback, wildcard, unspecified address가 아니고 allowlisted deployment + identity/member에 속한다; +- TLS hostname/SAN 검증을 통과한다; +- Sentinel credential 또는 trust를 data connection에, data material을 Sentinel connection에 + 재사용하지 않는다. + +한 Sentinel의 응답, 최초 응답 또는 DNS 문자열 일치만으로 primary를 바꾸지 않는다. discovery +실패 detail에는 endpoint, username, secret reference/value, certificate subject를 남기지 않고 +sanitized reason과 동의 수만 남긴다. + +#### 37.13.2 bounded rediscovery와 runtime swap + +정상 polling은 bounded single-flight로 실행하며, write/read command의 topology failure가 +발생하면 같은 single-flight에 bounded immediate rediscovery를 요청한다. 새 primary가 +qualification을 통과하면: + +1. 새 data runtime을 생성한다; +2. version/program/semantic readiness를 검증한다; +3. 기존 `RedisRoleCommandRouter`에 한 번만 install한다; +4. 기존 runtime은 새 admission을 닫고 in-flight command를 bounded drain한다; +5. drain timeout 뒤에는 강제 close하되 완료되지 않은 mutation을 성공/미실행으로 추정하지 않는다. + +failover 직전 또는 도중의 mutation은 자동 replay하지 않는다. transport가 실행 여부를 증명하지 +못하면 capability가 `INDETERMINATE`를 반환하고, idempotency/session은 같은 operation token의 +inspect/reconcile 또는 재인증 경로를 사용한다. read-only command도 semantic contract가 허용하는 +경우에만 새 runtime에서 재시도한다. + +`snapshot()`/readiness scrape는 정상 polling의 실행 엔진으로 사용하지 않는다. scrape나 command가 +없는 동안에도 primary 변경을 발견해야 하므로, active Sentinel role이 하나 이상일 때만 registry가 +다음 bounded poller를 소유한다. + +- registry당 daemon worker 1개와 active Sentinel role당 fixed-delay task 1개만 만든다; +- 기본 polling period는 30초이고 typed setting은 5초 이상 5분 이하만 허용한다; +- scheduled poll과 command-failure trigger는 role별 같은 single-flight를 공유하며 한 role에 + discovery/install 작업은 최대 1개만 실행하거나 대기한다; +- Standalone/Cluster만 선택되거나 Redis capability가 비활성이면 poller/thread/task를 0개 만든다; +- close는 새 trigger를 거절하고 scheduled task를 취소한 뒤 worker를 bounded shutdown하며, + close와 경합해 늦게 생성된 candidate는 install하지 않고 정확히 한 번 닫는다. + +command failure signal은 route lease가 반환된 뒤 발행한다. connection/timeout/topology 계열의 +`UNAVAILABLE`만 immediate rediscovery를 요청하고, overload, ACL denial, validation/size rejection은 +요청하지 않는다. signal listener의 실패는 원래 command의 `NOT_APPLIED`/`INDETERMINATE` 판정을 +절대 덮어쓰지 않는다. + +정상 poll은 Sentinel discovery credential/CA만 사용해 endpoint를 조회한다. 현재 route와 같은 +primary면 data credential/CA를 해석하거나 새 data connection을 열지 않는다. primary가 달라졌을 +때만 이미 quorum-approved/allowlisted 된 exact endpoint로 data candidate를 열어 TOCTOU 성격의 +이중 discovery를 피한다. route는 endpoint를 출력하지 않는 package-private identity와 monotonic +generation token을 가진다. candidate qualification 중 다른 rotation이 먼저 완료되면 stale +generation candidate를 닫고 install하지 않는다. 같은 identity도 candidate를 닫고 no-op 처리한다. + +#### 37.13.3 replication 보장과 판정 + +Sentinel은 primary election을 제공하지만 asynchronous replication의 zero-data-loss를 보장하지 +않는다. qualification 환경은 correctness role에 `min-replicas-to-write`와 bounded +`min-replicas-max-lag`를 설정하고, 중요한 mutation은 명시된 replica acknowledgement 정책을 +사용한다. 이 설정도 strong consistency나 cross-store exactly-once 증거가 아니다. + +failover 판정은 다음을 구분한다. + +- 응답과 요구된 replica acknowledgement가 확인된 mutation: 새 primary에서 보존되어야 한다; +- response-only cut 또는 acknowledgement 결과를 확인할 수 없는 mutation: + `INDETERMINATE`, blind retry 금지; +- acknowledgement 전 명확한 connection/admission 실패: `NOT_APPLIED`가 wire evidence로 + 증명되는 경우에만 미실행으로 판정한다. + +#### 37.13.4 Sentinel-first R2 qualification lab + +이번 Phase 5의 첫 실행 slice는 기존 host k3s를 변경하지 않는 disposable Multipass lab이다. + +```text +ca-redis-lab-server 2 CPU / 3 GiB / 12 GiB k3s server +ca-redis-lab-agent-1 2 CPU / 2.5 GiB / 12 GiB k3s agent +ca-redis-lab-agent-2 2 CPU / 2.5 GiB / 12 GiB k3s agent +pod CIDR 10.52.0.0/16 +service CIDR 10.53.0.0/16 +kube context ca-redis-lab +``` + +lab kubeconfig와 transient material/raw observation은 Gradle root의 ignored +`src/build/redis-lab` 아래에만 쓰며 사용자의 default kubeconfig에 merge하거나 덮어쓰지 않는다. +host 관측에는 default kubeconfig의 run-scoped copy와 시작 시점의 exact host context를 +사용하지만, fingerprint/CIDR 관측이 끝난 즉시 성공/실패와 무관하게 copy를 제거한다. 모든 +lab mutating command는 별도 lab kubeconfig와 `ca-redis-lab` context를 함께 요구한다. +VM 이름은 위 exact allowlist만 허용한다. launch 전에 exact name을 run-owned state에 +`PENDING`으로 atomic 예약하고 성공 직후 `CREATED`로 승격한다. timeout, partial create, +state 승격 실패는 이 run이 예약한 exact name만 delete/purge한다. global `multipass purge`, +host `kubectl delete`, default-context write는 금지한다. + +run-scoped rendered cloud-init은 secret이 아닌 exact `RUN_ID|VM_NAME` ownership marker를 +instance에 기록한다. cleanup/down은 bounded marker read가 state owner와 name 일치를 +증명할 때만 delete한다. launch timeout/error는 `RECONCILE` tombstone과 bounded late-create +poll로 처리한다. instance가 끝까지 없거나 marker가 unreadable/mismatch면 외부 same-name +instance를 추측해 삭제하지 않고 state를 유지한 채 fail-closed한다. + +lifecycle 전체는 nonblocking exclusive lock과 run identity를 사용한다. direct `up`과 +`run` 모두 첫 launch 전에 emergency cleanup을 활성화하며 signal/concurrent invocation이 +다른 run의 state 또는 VM을 채택·삭제하지 못한다. `run -- `에는 lifecycle lock file +descriptor를 상속하지 않는다. K3s는 mutable installer를 pipe로 실행하지 않고 exact release +URL/SHA-256을 repository에 pin한다. host download와 각 VM transfer 뒤 checksum/version을 +다시 확인한 후에만 start한다. +기본 bounded external child도 lifecycle lock descriptor를 닫으며 lock acquisition만 +명시적인 keep-lock 경로를 사용한다. +`run`의 inner `up` 성공과 user command 시작 사이에도 cleanup-required flag는 연속 유지되며, +signal handler가 ownership을 0으로 보는 handoff gap을 허용하지 않는다. +lab kubeconfig renderer는 one-cluster/context/user schema의 모든 identity-bearing key를 +generic count하며 duplicate/extra server, context cluster/user, item/name, +current-context를 last-key-wins로 남기지 않고 fail-closed한다. + +#### 37.13.4.1 lab lifecycle 완료 경계와 strict kubeconfig renderer + +`Task 11.1A`는 하나의 리뷰 단위로 너무 많은 책임을 가졌으므로 다음 두 하위 작업으로 분리한다. + +- `Task 11.1A-1`: VM 이름/소유권 marker, `PENDING|CREATED|RECONCILE` state, lock FD, + signal/handoff cleanup, host fingerprint와 bounded external command를 소유한다. +- `Task 11.1A-2`: pinned K3s admin kubeconfig의 strict validation과 lab 전용 rename/render만 + 소유한다. + +`11.1A-1` 코드는 `11.1A-2` 동안 동결한다. `11.1A-2`가 독립 테스트와 독립 리뷰를 통과하기 +전에는 부모 `11.1A`를 완료로 표시하지 않으며 VM 생성도 허용하지 않는다. + +`11.1A-2`는 범용 YAML parser가 아니다. 입력은 pinned K3s가 생성하는 admin kubeconfig의 +canonical block-style 문서 하나로 제한한다. 별도 tracked +`infra/redis-lab/lib/render-kubeconfig.awk`가 line/indentation/state allowlist를 적용하며, +identity-bearing key를 찾는 denylist나 발견된 mutation별 정규식 패치를 사용하지 않는다. + +허용 grammar는 다음을 모두 만족해야 한다. + +- top-level `apiVersion`, `clusters`, `contexts`, `current-context`, `kind`, `preferences`, + `users`는 canonical 순서와 exact spelling/indentation으로 한 번만 존재한다; +- cluster/context/user list는 각각 한 항목만 가지며 identity는 모두 exact `default`다; +- cluster는 exact loopback `server: https://127.0.0.1:6443`와 하나의 + `certificate-authority-data` scalar만 가진다; +- context는 exact `cluster: default`, `user: default`와 optional single `namespace` scalar만 + 가진다; +- user는 하나의 `client-certificate-data`와 `client-key-data` scalar만 가진다; +- `preferences: {}`만 유일한 flow collection 예외다. 그 밖의 `{}`, `[]`, quoted/tagged/ + explicit key, anchor, alias, merge key, tab, CRLF, YAML document marker, unknown key, + duplicate/reordered identity, trailing content는 fail-closed한다; +- source `server`, cluster/context/user name과 current-context만 변환한다. CA/client material, + namespace와 그 밖의 허용 scalar는 byte-preserving pass-through다; +- renderer source 자체와 destination의 canonical parent/symlink/permission 계약을 lifecycle + static validation에 포함한다. validation 또는 render 실패 시 destination을 제거하고 + constant sanitized failure만 출력한다. + +정상 fixture는 pinned K3s admin kubeconfig의 certificate-data shape를 사용한다. negative +mutation은 duplicate/extra identity뿐 아니라 canonical item 아래의 sibling +`cluster : {...}`, `context : {...}`, whitespace-before-colon, flow collection, quoted/tagged/ +anchor/alias/merge, unknown/reordered/missing key를 포함한다. 모든 실패는 lab `kubectl` 전에 +발생하고 현재 invocation이 marker로 증명한 VM만 cleanup하며 prior +`CREATED|RECONCILE` state는 byte-for-byte 보존한다. + +tracked `infra/redis-lab`에는 lifecycle script, cloud-init template, Redis/Sentinel config +template, Kubernetes manifest와 secret 없는 contract test만 둔다. 실행 시 생성하는 k3s token, +ACL password, data/Sentinel/untrusted CA와 private key, rendered Secret/config, raw observation은 +`umask 077`인 transient directory에만 둔다. `redis-cli --pass`, tracked PEM/Secret data, +`hostPath`/`hostNetwork`/privileged/NodePort/LoadBalancer는 사용하지 않는다. + +host isolation은 preflight/postflight의 canonical projection을 비교한다. default kubeconfig +digest, current context/API, sorted node/providerID/podCIDR, controller replica, Service NodePort, +host interface/route CIDR와 Multipass inventory가 대상이다. host service CIDR은 현재 할당된 +ClusterIP만 보고 추측하지 않고, 명시적으로 검증한 input 또는 신뢰할 수 있는 host 설정에서 +읽는다. 외부 명령과 exact 3-node Ready 대기는 bounded다. 불일치 시 qualification을 +실패시키되 script가 host 상태를 추측해 되돌리려고 mutate하지 않는다. + +workload는 Redis primary 1 + replica 2, Sentinel 3/quorum 2를 서로 다른 node에 배치한다. +data와 Sentinel은 stable ordinal/headless DNS가 필요한 별도 StatefulSet이며 +`kubernetes.io/hostname` required anti-affinity와 `maxSkew=1/DoNotSchedule` topology spread를 +사용하고 `podManagementPolicy: Parallel`을 명시한다. data는 PVC와 AOF +`appendfsync everysec`를 사용한다. Sentinel config는 discovery/failover 시 rewrite되므로 +bootstrap 원본을 pod별 writable PVC config로 최초 1회 atomic init-copy하되 restart 때 이미 +존재하는 rewritten config를 덮어쓰지 않는다. 비어 있거나 손상된 기존 config도 자동으로 +덮지 않고 startup을 실패시켜 증거를 보존한다. + +data/Sentinel plaintext port는 0이며 TLS port만 연다. `tls-replication yes`, hostname +resolution/announcement와 stable DNS SAN을 사용한다. data plane과 Sentinel plane의 CA/leaf +material은 분리하며 peer 연결에 필요한 root만 explicit trust bundle에 포함한다. ACL은 +application data, replica, Sentinel-to-data, Sentinel peer, application Sentinel discovery +identity로 나눈다. Redis data ACL과 Sentinel ACL은 별도 template/projection이며 plane +identity를 서로 노출하지 않는다. default user는 off이며 application/data/discovery +identity에는 `+@all`, `allkeys`, `allchannels`를 주지 않는다. replica는 +`+psync +replconf +ping`, Sentinel-to-data identity는 Sentinel control에 필요한 최소 +command/channel set만 가진다. + +exec probe를 사용하고 default-deny NetworkPolicy 뒤 data 6379, Sentinel 26379, kube-dns, +exact qualification/application pod selector만 허용한다. data/Sentinel PDB는 각각 +`minAvailable: 2`이며 non-root, read-only root filesystem, privilege-escalation false, +capability drop ALL, seccomp RuntimeDefault, requests/limits를 요구한다. `hostPath`, +host namespaces, privileged, NodePort/LoadBalancer와 tracked Secret/PEM은 금지한다. + +정적 lifecycle contract와 manifest/security contract는 VM 없이 blocking check에서 검증하고, +한 필드씩 제거/변조하는 mutation-negative fixture로 실제 방어력을 확인한다. 이 정적 통과는 +TLS handshake, ACL authorization, CNI enforcement, scheduling/failover의 실행 증거가 아니다. +shell contract는 별도 fixture repository만 사용하며 actual `src/build/redis-lab` state를 +byte-for-byte 보존한다. fake PATH는 explicit safe wrapper 외 모든 명령을 fail-closed한다. +live lab에서는 TLS/ACL negative test, `SENTINEL CKQUORUM`, writable config rewrite/restart, +exact 3 Ready placement, PDB/NetworkPolicy enforcement와 image ID/digest를 별도로 검증한다. +Redis image는 `src/gradle/redis-test-images.properties`의 `redis.minimum.image` exact +tag+digest를 사용한다. + +ordinal bootstrap은 최초 `redis-data-0` primary와 두 replica만 정적으로 증명한다. +failover 동안 죽어 있던 old primary가 재합류할 때 readiness가 stale direct write를 허용하지 +않고 새 primary의 replica로 수렴하는지는 live gate다. PDB 선언은 voluntary eviction +제약일 뿐 node/AZ failure 증거가 아니다. + +k3s control-plane HA, physical host/AZ failure, Redis Cluster는 이 lab의 증거가 아니다. +hosted GitHub Actions에서는 Multipass를 설치하거나 실행하지 않는다. 실제 lab qualification은 +trusted dedicated runner 또는 local explicit execution에서만 허용한다. 외부 PR 코드를 +self-hosted lab에서 실행하지 않는다. + +초기 test budget은 운영 SLA가 아니라 bounded regression limit이다. + +- Sentinel election: 60초 이내; +- client rediscovery와 runtime swap: election 뒤 추가 30초 이내; +- required semantic readiness 복구: fault injection 뒤 총 90초 이내. + +실제 측정값을 evidence timeline에 기록하며 limit만 기록한 문서는 증거가 아니다. + +#### 37.13.5 Sentinel-first capability acceptance + +이 slice는 correctness-sensitive cross-pod state를 우선 검증한다. + +- edge rate limit: failover 전 quota state가 조용히 reset되지 않고 evaluation replay가 일관된다; +- request-replay idempotency: claim/start/renew/complete와 terminal replay가 owner-safe하며 + 불확실 mutation은 중복 실행하지 않는다; +- Redis session: create/read/touch/rotate/revoke가 서로 다른 application pod에서 보이고, + failover 뒤 confirmed state가 유지되며 stale session이 부활하지 않는다; +- cache refresh soft lease와 optional cache는 공통 runtime 회귀를 확인하되 이 slice만으로 + Cluster scaling 또는 distributed L1 invalidation R2를 주장하지 않는다. + +fault 순서는 baseline qualification 뒤 current primary pod를 kill하고 readiness unavailable, +Sentinel quorum election, client rediscovery, runtime swap/drain, semantic readiness recovery를 +실제 timestamp로 수집한다. old primary는 replica로 재합류해야 하고, recovery 뒤 모든 actor가 +같은 runtime generation을 관측해야 한다. + +evidence bundle은 실제 실행 image digest/image ID, config/program digest, fault/election/recovery +timeline, capability별 outcome/certainty, sanitized Kubernetes/Sentinel observation, lab teardown +결과를 포함한다. manifest의 `NOT_CAPTURED`를 문자열로 바꾸는 것만으로 증거를 만들 수 없다. + ### 37.14 Cluster topology 최소 multi-primary Cluster와 replica에서: @@ -6115,20 +6398,9 @@ canonical card ID와 Gradle task mapping: registry key, capability descriptor ID, `card-` tag, evidence artifact의 card ID는 이 표와 byte-for-byte 같아야 한다. short alias를 허용하지 않는다. -```yaml -cards: - redis-cache: - state: selected # selected | implemented-candidate | not-implemented - selected-topology: sentinel # standalone | sentinel | cluster - required-evidence: - - standalone - - security - - fault - - compatibility - - selected-topology - redis-session: - state: not-implemented -``` +현재 card 상태와 topology/evidence는 이 문서에 복제하지 않으며 +`src/config/redis/readiness-cards.yaml`만을 따른다. 현재 `selected` card는 없으며, +`implemented-candidate`는 release selection 또는 R2 qualification을 뜻하지 않는다. 각 `redisReadiness` task는 이 registry의 해당 card tag와 required evidence tag의 교집합을 실행하고, category마다 test count > 0, 성공 artifact, image/program/config digest를 요구한다. @@ -6224,10 +6496,14 @@ nightly `redis-all-candidates`는 `redisAllImplementedCandidates`를 실행한 품질 신호/승격 blocker지만 현재 selected card의 이미 존재하는 release evidence를 다른 card 미구현 때문에 자동 취소하지 않는다. -각 job은 JUnit XML/HTML, container logs, sanitized topology/fault timeline, -`program-set.json`/digest, effective capability card, image digest attestation을 artifact로 올린다. -secret, raw Redis key/value, session/idempotency token은 artifact에 포함하지 않는다. PR artifact -retention은 짧게, release evidence는 조직의 audit retention 정책에 맞춘다. +각 job은 `build/redis-evidence` 아래에서 allowlist schema로 다시 생성한 bounded manifest, +capability card, sanitized test summary만 artifact로 올린다. Gradle의 raw JUnit XML/HTML, +`system-out`/`system-err`, stack trace, container log/inspect, TLS/ACL fixture material은 업로드하지 +않는다. 실제 사용 image attestation과 실제 topology/fault event chain을 수집하지 못한 현재 +artifact는 각각 `NOT_CAPTURED`와 `releaseQualification=NOT_CLAIMED`를 기록하며, reconciler는 +이 상태의 future `selected` 승격을 실패시킨다. secret/reference value, raw endpoint/key/value, +session/idempotency/lease token은 artifact에 포함하지 않는다. Candidate artifact retention은 +짧게, 실제 release evidence는 조직의 audit retention 정책에 맞춘다. ### 37.24 no silent skip @@ -6526,6 +6802,36 @@ Acceptance: - no silent skip; - program/ACL/schema conformance. +#### Phase 5A — Sentinel-first R2 qualification slice + +Phase 5 전체를 한 번에 구현하지 않는다. 먼저 §37.13의 disposable 3-node k3s Sentinel 환경에서 +다음 순서로 진행한다. + +1. lab lifecycle/preflight/host-isolation contract를 테스트 우선으로 고정한다; +2. Sentinel discovery와 Redis data runtime을 별도 auth/trust/lifecycle로 구현한다; +3. quorum-consistent discovery, bounded rediscovery, qualified runtime swap와 bounded drain을 + 구현한다; +4. security positive/negative test 후 rate limit, idempotency, session의 multi-pod 정상 경로를 + 실행한다; +5. primary kill과 response-loss fault를 주입하고 capability invariant와 `INDETERMINATE` + semantics를 검증한다; +6. image/fault timeline을 실제 관측에서 생성하고 sanitizer/reconciler를 통과시킨다; +7. focused/full Gradle verification과 독립 review를 마친 뒤 이 slice에서 멈춘다. + +이번 slice에 포함하지 않는 항목: + +- Redis Cluster와 Cluster cache scaling; +- fenced coordination; +- R3 capacity soak/long chaos/reshard; +- k3s control-plane HA, physical host/AZ failure; +- full credential/certificate rotation drill; +- optional cache의 Sentinel release promotion. + +이번 slice의 agent-side 종료 상태는 `R2-ready candidate`다. repository가 human-only commit +policy를 사용하므로 clean committed source와 실제 remote GitHub Actions evidence는 사람이 +수행하는 최종 promotion gate다. 이 두 증거가 없으면 readiness card를 `selected`로 바꾸거나 +R2라고 표시하지 않는다. + ### Phase 6 — R3와 split review - actual Cluster reshard/failover; @@ -6573,6 +6879,28 @@ Acceptance: - runbook/capability card; - LLM Wiki capture. +### 40.2 Sentinel-first slice 종료 게이트 + +§37.13과 Phase 5A의 작업은 아래가 모두 충족된 경우에만 `R2-ready candidate`로 종료한다. + +- exact VM inventory와 dedicated kubeconfig로 lab create/verify/destroy가 반복 가능하다; +- host k3s context, node, workload와 default kubeconfig의 전/후 fingerprint가 같다; +- Sentinel discovery와 Redis data auth/trust가 분리되고 negative security test가 통과한다; +- primary kill 뒤 quorum election, qualified runtime swap, bounded drain과 semantic readiness + recovery의 실제 timeline이 있다; +- rate limit, idempotency, session을 서로 다른 pod에서 검증하고 failover 뒤 invariant가 + 유지된다; +- confirmed acknowledgement와 `INDETERMINATE`를 구분하며 blind mutation replay가 없다; +- actual image/config/program digest와 sanitized evidence가 reconciler를 통과한다; +- focused test, Redis readiness 관련 task, repository `test`/`check`, architecture/env/public-path + gate와 독립 review가 통과한다; +- exact allowlist VM teardown과 lab resource 정리 결과가 기록된다. + +위 조건은 clean committed source와 실제 remote CI를 대신하지 않는다. 두 최종 promotion +증거가 없으면 card 상태는 `implemented-candidate`, `releaseQualification=NOT_CLAIMED`를 +유지한다. 종료 뒤 Redis Cluster/R3/fenced coordination 또는 fileserver/HTTP client로 자동으로 +넘어가지 않고 다음 우선순위를 다시 결정한다. + R3는 추가로: - failover/partition; diff --git a/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md b/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md index dffa0c0..796a581 100644 --- a/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md +++ b/docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md @@ -1,7 +1,7 @@ # HTTP Client Production Capability Deep Design - 작성일: 2026-07-27 -- 상태: 상세 설계 완료, Phase 0/1 기반 및 legacy deadline R1 구현, R2 미구현 +- 상태: 상세 설계 완료, Phase 0/1 기반·legacy deadline R1·canonical zero-binding 구현, R2 미구현 - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 대상 leaf: `adapter-outbound-httpclient` - 구현 추적: typed operation/target foundation, legacy JDK 안전 결함과 active logical deadline @@ -28,11 +28,16 @@ - client별 bounded live-worker admission, non-cooperative worker의 slot retention, shutdown 시 active task cancellation과 신규 admission 차단; - worker MDC 복사/정리와 retry ThreadLocal lifecycle 정렬. +- strict canonical expected-state/binding/provider map binder와 exact provider/destination/catalog + resolver; +- `DISABLED_VERIFIED` descriptor와 zero-binding HTTP runtime resource 0 composition; +- `httpclient-static-buffered=NOT_IMPLEMENTED` fail-closed ACTIVE admission; +- legacy settings/configuration의 global Spring scan 분리와 explicit migration binder. 아직 구현되지 않은 범위: - application feature-specific production port와 실제 upstream anti-corruption adapter; -- canonical binding/expected-state/full profile tuple/card registry와 zero-binding resource 0 계약; +- full compatibility profile tuple/scenario registry와 release-eligible readiness evidence; - Apache HC5 pool/acquire/lifetime/idle provider; - Apache engine phase별 deadline 전달, wire hard cancellation과 connection quarantine; - DNS/address/SSRF/TLS/mTLS/proxy/auth/secret lifecycle; @@ -42,7 +47,8 @@ 따라서 현재 `OutboundHttpClient`는 migration용 JDK R1 이하 facade이며 HTTP capability R2가 아니다. legacy 실행 경로는 active logical deadline을 사용하지만 operation catalog와 engine phase -deadline을 아직 사용하지 않는다. 이 단면만으로 hard cancellation이나 R2를 주장하지 않는다. +deadline을 아직 사용하지 않는다. Canonical ACTIVE도 현재 `NOT_IMPLEMENTED` card에서 실패한다. +이 단면만으로 hard cancellation이나 R2를 주장하지 않는다. ## 1. 설계 판정 @@ -147,11 +153,12 @@ production capability는 아니다. dependency별 configuration에서 `baseline(...)`을 직접 호출하도록 안내한다. repository 전체에서 이를 호출하는 production consumer는 없다. -다만 “binding 0개”가 HTTP 관련 bean 0개라는 뜻은 아니다. Component scan이 이 configuration을 -읽으면 `OutboundHttpSettings`, shutdown guard, `RestClient`/builder 차단 BeanPostProcessor, -error mapper, logger와 retry policy 같은 global infrastructure bean은 생성된다. Named -client/semantic-port binding은 없는데 required global timeout 설정과 전역 부작용은 존재하는 -비대칭 상태다. +초기 조사 시점에는 component scan이 `OutboundHttpSettings`, shutdown guard, +`RestClient`/builder 차단 BeanPostProcessor, error mapper, logger와 retry policy를 생성해 +“binding 0개”와 “HTTP resource 0개”가 일치하지 않았다. Phase 1 구현에서 이 결함은 폐쇄됐다. +현재 settings와 두 legacy configuration은 global scan 대상이 아니며 canonical composition은 +immutable configuration, registry, resolver와 sanitized `DISABLED_VERIFIED` descriptor만 만든다. +기본 `application.yml`과 `application-test.yml`도 legacy `app.outbound.http.*`를 선언하지 않는다. 다만 sample에는 이미 다음 seam이 있다. @@ -4893,6 +4900,11 @@ inbound/use-case budget ## 33. Configuration design +2026-07-28 구현 단면은 canonical expected-state/binding/provider map의 strict binding, exact +provider/destination/code-owned catalog resolution과 `httpclient-static-buffered` card derivation까지 +포함한다. 아래 full provider tuple의 pool/security/TLS/auth 필드는 아직 bind/runtime model로 +구현되지 않았다. + ### 33.1 Canonical activation shape 상위 capability platform과 같은 canonical prefix를 사용한다. @@ -5229,7 +5241,15 @@ Base URI, proxy endpoint, SSL bundle/secret reference 변경은 운영 영향이 ### 33.7 Legacy migration -현재 `app.outbound.http.*`는 migration-only alias다. +`app.outbound.http.*`는 canonical application configuration에 포함되지 않는 migration-only +입력이다. + +현재 구현은 global `@ConfigurationPropertiesScan`을 제거하고 +`OutboundHttpSettings.bindLegacy(Binder)`/직접 생성자만 남겼다. Canonical composition은 +expected state가 DISABLED여도 legacy property가 하나라도 보이면 silent no-op 대신 +fail-closed한다. Legacy fork는 canonical composition 밖에서 migration binder와 configuration을 +명시적으로 import해야 한다. 아래 deprecation warning, one-destination conversion, +release-window removal은 후속 migration 단계다. 1. legacy만 있으면 deprecation warning과 함께 immutable legacy settings로 변환; 2. canonical과 legacy가 동시에 있으면 값이 같아도 startup failure; @@ -5257,6 +5277,10 @@ Application은 adapter type, `RestClient`, Apache type을 알지 못한다. ### 34.2 Zero-binding contract +이 절의 resource 0 계약은 `HttpClientCompositionConfigTest`와 +`OptionalAdapterBeanGatingTest`로 구현됐다. 기본 composition은 inert registry/resolver/descriptor +외에 HTTP runtime bean을 만들지 않으며 `DISABLED_VERIFIED`만 게시한다. + Binding이 없으면 다음이 모두 0개여야 한다. - engine client와 connection manager; diff --git a/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md b/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md index ce8e386..764d2b9 100644 --- a/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md +++ b/docs/superpowers/specs/2026-07-28-fileserver-r2-control-plane-provider-selection-design.md @@ -1,7 +1,7 @@ # Fileserver R2 Control Plane and Provider Selection Design - Date: 2026-07-28 -- Status: 승인된 설계, 구현 전 +- Status: 구현·전체 repository gate·독립 spec/quality review 완료 - Scope: provider-neutral R2 control plane, explicit destination/provider selection, first `local-persistent` qualification provider - Parent: @@ -106,7 +106,9 @@ FILE_AND_DIRECTORY_SYNC fsr1... ``` -- `route-token`: startup에서 생성된 bounded destination route allowlist 값; +- `route-token`: destination binding의 canonical policy digest에서 재시작 안정적으로 파생한 + bounded route allowlist 값. 형식은 `r` + digest의 첫 31 lowercase hex이며 startup에서 token + collision을 거부한다; - `file-id`: CSPRNG 128-bit 이상; - `check-digits`: accidental truncation/corruption 검출; - provider locator, operation ID, tenant/user ID, host/path는 포함하지 않는다. @@ -146,6 +148,9 @@ app: - `enabled=true`이면 destination과 provider가 각각 하나 이상 필요하다. - 모든 destination은 존재하는 provider 하나를 참조한다. +- provider ID별로 provider/control/payload runtime을 정확히 하나만 만들며 같은 provider를 + 참조하는 destination은 그 인스턴스를 공유한다. 서로 다른 provider ID가 같은 normalized + root를 가리키면 동일 control namespace의 이중 소유가 되므로 startup에서 거부한다. - request destination에 binding이 없으면 producer 호출 전에 실패한다. - provider type의 기본값은 없다. - `local-persistent` root는 absolute, existing, pre-provisioned directory여야 한다. @@ -156,12 +161,34 @@ app: - container ephemeral 경로를 위한 `local-dev`는 별도 후속 profile이다. production 설정과 같은 guarantee를 공유하지 않는다. - 기존 `ca-skeleton.fileserver.*`는 R1/legacy compatibility selector로만 남는다. 새 R2 설정과 - 동시에 활성화되면 startup을 실패시킨다. 암묵 migration이나 precedence를 두지 않는다. + 동시에 활성화되면 어느 쪽 filesystem 초기화보다 먼저 startup을 실패시킨다. 양쪽 bean + factory가 같은 ambiguity validator를 호출해 Spring bean 생성 순서에 의존하지 않으며, 암묵 + migration이나 conditional precedence를 두지 않는다. +- R2 settings는 unknown field를 거부해 provider/destination 키 오타를 silent fallback으로 + 취급하지 않는다. ## 7. Startup capability compilation application traffic을 받기 전에 destination별 effective descriptor를 한 번 compile한다. +Descriptor compilation과 first reservation은 새 설정 키 없이 같은 canonical digest helper를 +사용한다. + +- startup descriptor는 destination ID, provider ID, limits, required guarantees, + format/encoder revision을 length-prefixed canonical encoding으로 직렬화한 + `effectivePolicyDigest`를 freeze한다; +- ordered schema ID/version/column contract를 같은 canonical encoding 규칙으로 계산하는 + request별 `schemaDigest`는 first reservation에서 계산한다; +- startup descriptor는 format/encoder revision과 canonical options의 `formatPolicyDigest`를 + freeze한다; +- `r` + `effectivePolicyDigest`의 첫 31 lowercase hex로 만든 32-character deterministic route + token. + +문자열 단순 연결이나 JVM/JSON map iteration order에 digest를 의존시키지 않는다. 같은 startup +allowlist 안에서 route token이 충돌하면 더 긴 prefix로 임의 복구하지 않고 startup을 실패시킨다. +기존 operation은 journal에 freeze된 revision/digest/token으로만 복구하며 현재 설정으로 조용히 +재해석하지 않는다. + `local-persistent`는 다음을 모두 검증한다. 1. root와 모든 ancestor가 symbolic link가 아니다. @@ -206,7 +233,9 @@ data// ``` 모든 locator는 validated single segment 또는 adapter가 생성한 bounded relative segment다. -Caller path를 받지 않는다. +Caller path를 받지 않는다. Manifest/reference의 `internalLocator`는 generated filename 한 +segment만 저장하고, data shard는 `fileId`의 첫 두 hex에서 파생한다. 따라서 실제 lookup은 +`data//`이며 control record에 slash를 저장하지 않는다. ### 8.1 Operation journal v2 @@ -267,6 +296,21 @@ relative locator로 direct lookup한다. Directory scan은 receipt restoration 순서로 갱신한다. 낮은 revision, fingerprint mismatch, newer schema는 자동 덮어쓰지 않는다. +Operation schema v2는 별도 `formatPolicyDigest` snapshot을 저장하지 않으므로 recovery는 저장된 +`effectivePolicyRevision`과 `effectivePolicyDigest`가 현재 compiled destination과 정확히 같을 +때만 현재 format-policy digest를 사용한다. Encoder/policy 변경으로 digest가 달라지면 과거 +format을 추정하지 않고 indeterminate로 중단한다. 여러 format revision에 대한 forward +recovery는 non-secret policy snapshot을 포함하는 후속 operation schema에서만 지원한다. + +Operation direct lookup은 같은 secure relative read에서 schema를 typed dispatch한다. Schema v2는 +현재 R2 record로만 decode/write하고, schema v1은 strict UTF-8 decode 후 canonical v1 re-encode +byte equality를 만족하는 terminal compatibility record만 read-only로 반환한다. Unknown/newer +schema, malformed UTF-8, non-canonical v1은 absent로 취급하지 않는다. + +Crash qualification을 위해 control-plane fault context는 package-private로 record kind, +record identity, 해당하는 경우 operation state/revision, force boundary를 함께 전달한다. +Production 기본 callback은 no-op이며 runtime 설정이나 public bean으로 노출하지 않는다. + ## 9. Publication ordering ```text @@ -290,6 +334,18 @@ J-PUBLISHED - terminal journal force 전에는 receipt를 반환하지 않는다. - target collision, digest mismatch 또는 root identity change는 자동 overwrite하지 않는다. - final data가 있어도 manifest/reference가 없으면 아직 terminal success가 아니다. +- staging/data shard 생성, stage force, stable no-follow read/digest, exact delete는 + package-private `PayloadOperations`를 통해 `SecureDirectoryStream` 상대 연산으로 수행한다. + Portable relative primitive가 없는 hard-link와 directory force만 private-owner boundary 안에서 + root/directory/file identity pre/post 검증으로 감싼다. +- hard-link 뒤 journal 갱신 전에 중단된 `SEALED + matching data` 복구는 기존 data shard를 다시 + identity 검증하고 directory force한 뒤에만 `DATA_PUBLISHED`로 전이한다. 이미 존재하는 data를 + overwrite-capable publication 경로에 다시 넣지 않는다. +- `WRITING` 저장 뒤 producer 또는 stage/write가 실패하면 partial stage를 exact cleanup하고 + unsealed `QUARANTINED` evidence를 남긴다. 원래 producer exception은 보존하고 cleanup/control + failure는 suppressed로 연결한다. Retry 진입 시 기존 `WRITING` 또는 unsealed + `QUARANTINED`가 보이면 producer를 다시 호출하지 않고 indeterminate/quarantine으로 + fail-closed한다. ## 10. Deterministic recovery @@ -303,7 +359,9 @@ Recovery는 operation ID direct lookup으로 실행하며 startup full scan에 | DATA_PUBLISHED + matching data | manifest publication 재개 | | MANIFEST_PUBLISHED + matching manifest/data | reference publication 재개 | | REFERENCE_PUBLISHED + all matching | terminal journal 완성 | -| data digest mismatch | `QUARANTINED`, integrity failure | +| non-terminal data/manifest/reference digest mismatch | `QUARANTINED`, integrity failure | +| `PUBLISHED` artifact/metadata/receipt mismatch | terminal journal과 artifacts를 불변 보존하고 typed integrity/indeterminate | +| required manifest/reference/data 누락 | 성공 복원 금지, fail-closed indeterminate/quarantine | | marker/manifest/reference schema newer | 보존 후 fail-fast/quarantine | | fingerprint conflict | typed conflict, 기존 artifact 보존 | | root/mount identity change | indeterminate, write/recovery 중단 | @@ -317,12 +375,35 @@ matching data + private manifest + reference > in-memory state ``` -모순이 있으면 임의 성공이나 삭제 대신 quarantine evidence를 기록한다. +모순이 있으면 임의 성공이나 삭제를 하지 않는다. Non-terminal operation은 기존 operation +journal을 `QUARANTINED`로 전이할 수 있다. 이미 `PUBLISHED`인 operation은 terminal +receipt snapshot을 지우거나 journal을 덮지 않고 관련 data/manifest/reference도 보존한 채 typed +integrity/indeterminate로 실패한다. 별도 immutable quarantine incident record는 후속 설계 전까지 +가정하지 않는다. + +Recovery verifier는 operation, incoming request, data, manifest, reference, receipt snapshot의 +identity/digest/locator/count/time/guarantee를 모두 교차검증한다. Terminal receipt는 verified +manifest/reference에서 재구성한 expected receipt와 전체 equality가 확인될 때만 반환한다. +Operation record의 일부 필드만 맞거나 durability/publication guarantee, file version, +format/media/charset가 다르면 terminal success가 아니다. Crash 뒤 먼저 발견한 immutable +manifest/reference의 verified `publishedAt`은 새 clock 값으로 덮지 않고 recovery context로 +재사용한다. 새 attempt에만 현재 configured maximum을 적용하고, sealed recovery artifact는 +operation에 freeze된 exact byte size로 bounded inspection한다. Stage와 data가 함께 있으면 +digest equality만이 아니라 stable file key가 같은 hard-link인지 확인한 뒤에만 stage를 +exact-delete한다. ## 11. Compatibility -- R1 journal schema v1은 읽을 수 있어야 한다. +- R1 compatibility는 별도 미설정 root나 동시에 활성화된 legacy bean이 아니다. Operator가 기존 + R1 root를 owner/mode/FileStore/sentinel 등 R2 attestation 조건에 맞춰 명시적으로 + pre-provision한 뒤, 그 root를 R2 destination으로 전환하는 in-place read-only migration이다. +- R1과 R2 operation journal은 같은 hashed path를 사용하므로 secure relative typed schema + dispatch로 schema v1을 읽고 schema v2만 쓴다. +- R1 journal schema v1은 strict UTF-8와 canonical re-encode byte equality를 만족하는 terminal + record만 읽을 수 있어야 한다. - R1 terminal receipt는 기존 `PROCESS_LOCAL_SYNC` 보장 그대로 복원한다. +- R1 root-level artifact도 attested root의 `SecureDirectoryStream` 상대 no-follow bounded + streaming inspection으로 journal의 byte size와 SHA-256을 확인한 뒤에만 receipt를 복원한다. - R1 artifact를 자동으로 R2 manifest/reference로 승격하지 않는다. - R2 writer는 journal v2만 생성한다. - 기존 overwrite-capable legacy port는 별도 root와 opt-in을 유지하며 R2 control plane에 접근하지 @@ -334,9 +415,12 @@ matching data + private manifest + reference - 설정/보장 mismatch: startup failure; - destination 없음: producer 전 deterministic request failure; - stage 이전 capacity/validation failure: not applied; -- stage/write failure: failed, partial stage는 recovery evidence가 아니면 정리; +- stage/write failure: failed, partial stage는 recovery evidence가 아니면 exact cleanup하고 + unsealed `QUARANTINED`로 producer replay를 차단; - sealed 이후 filesystem timeout/IO/root identity change: indeterminate; -- published data와 metadata 불일치: integrity/quarantine; +- non-terminal published data와 metadata 불일치: integrity/quarantine; +- terminal `PUBLISHED` data/metadata/receipt 불일치: terminal evidence 불변 보존 후 typed + integrity/indeterminate; - journal/control record corruption: provider exception을 노출하지 않고 typed indeterminate; - guarantee를 낮춰 성공시키는 fallback은 없다. @@ -348,6 +432,8 @@ matching data + private manifest + reference - R1/R2 simultaneous activation rejection; - reference grammar/check digits/forged route rejection; - journal v2, manifest, reference canonical round-trip; +- deterministic route token collision rejection과 canonical policy/schema/format digest; +- same operation path의 strict canonical R1 read-only/v2 write-only typed dispatch; - state revision과 fingerprint conflict; - achieved durability value invariants. @@ -360,8 +446,10 @@ matching data + private manifest + reference - successful capability probe와 cleanup; - partial final visibility 0건; - same operation concurrency와 producer once; +- unsealed `WRITING` failure quarantine와 retry producer 0회; - target collision no overwrite; -- data/manifest/reference digest mismatch quarantine. +- non-terminal data/manifest/reference digest mismatch quarantine; +- terminal mismatch의 PUBLISHED journal/artifact 불변 보존과 typed integrity/indeterminate. ### 13.3 Crash qualification @@ -390,6 +478,11 @@ terminal journal directory force partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용하지 않는다. +같은 attested root와 operation ID에 대해 process A가 OS operation lock을 보유하는 동안 forked +process B의 bounded non-blocking/timed acquire가 critical section에 진입하지 못하고, A의 +release 또는 강제 종료 뒤 B가 획득하는지도 별도로 증명한다. 이 증거는 동일 JVM stripe 테스트로 +대체하지 않는다. + ### 13.4 플랫폼 - Linux/POSIX + `SecureDirectoryStream` + directory force qualification lane에서만 @@ -413,3 +506,41 @@ partial final, overwrite, 다른 receipt, silent guarantee downgrade는 허용 8. 문서와 receipt는 `local-persistent` qualification만 R2라고 표시한다. 후속 순서는 Phase 3 maintenance/resource limits, Phase 4 SFTP, Phase 5 shared-mounted/NFS evidence다. + +## 15. 구현 및 readiness 판정 + +2026-07-28 구현은 다음 경계를 만족한다. + +- application에는 provider/path/framework 타입이 없는 `FilePublicationPort`만 유지한다. +- adapter 내부의 canonical operation/manifest/reference model, opaque reference, provider SPI, + exact destination router는 provider-neutral control/selection boundary로 구현되었다. +- `app.fileserver.enabled`는 disabled-default이며, enable 시 destination/provider를 exact + compile한다. Unknown destination은 producer 호출 전에 실패하고 implicit local fallback은 + 없다. +- 같은 provider ID를 참조하는 destination은 하나의 provider/control/payload runtime을 + 공유한다. 서로 다른 provider ID가 같은 normalized root를 소유하면 startup에서 실패한다. +- R2 provider는 `local-persistent` 하나만 구현·qualification한다. Absolute/existing + pre-provisioned root와 owner/mode/FileStore/sentinel/path/capability attestation이 모두 + 성공해야 bean이 구성된다. +- operation v2, private manifest, direct reference index, ordered force publication과 + deterministic recovery를 구현했다. Forked-process qualification은 각 force boundary와 OS + operation lock을 대상으로 하며, focused/module/full gate 결과와 함께 완료 증거를 판정한다. +- 기존 schema-v1 terminal record와 root-level R1 artifact는 strict UTF-8/canonical/direct + read-only compatibility다. 원래 `PROCESS_LOCAL_SYNC` receipt만 복원하며 schema-v2 rewrite, + manifest/reference 생성, `FILE_AND_DIRECTORY_SYNC` 자동 승격을 하지 않는다. + +`FILE_AND_DIRECTORY_SYNC`는 attested local filesystem protocol에서 file과 관련 directory +force가 성공했다는 의미다. Physical device, volatile storage-controller cache, volume replica, +backup 또는 site 단위 power-loss protection을 주장하지 않는다. 그 보장은 Fileserver 코드가 +아니라 선택한 storage/deployment의 별도 evidence가 필요하다. + +다음 capability는 구현되지 않았고 setting/env/bean으로 노출하지 않는다. + +- `shared-mounted`/NFS multi-client correctness와 cross-node producer fencing; +- SFTP SDK, connection/session pool, host-key/credential, remote reconciliation; +- background reconcile/reaper, managed retention/delete; +- quota reservation, backpressure, capacity admission; +- Fileserver 전용 readiness/health, metrics, tracing, audit. + +따라서 이 increment의 운영 claim은 “모든 Fileserver topology가 R2”가 아니라 +“strictly attested `local-persistent` profile만 R2”다. diff --git a/docs/superpowers/specs/2026-07-28-jpa-production-capability-design.md b/docs/superpowers/specs/2026-07-28-jpa-production-capability-design.md new file mode 100644 index 0000000..d84ab5d --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-jpa-production-capability-design.md @@ -0,0 +1,4824 @@ +# JPA/PostgreSQL Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: 상세 설계 완료, 현행 기능별 R1 이하, JPA/PostgreSQL capability 전체 R2 미달 +- 기준: Java 21, Spring Boot 4.0.0, Hibernate ORM 7.1.8, PostgreSQL 16, + Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-persistence-jpa` +- Gradle path: `:adapter:outbound:persistence-jpa` +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- 관련 심화 문서: + [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md), + [FileServer Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md), + [Messaging Production Capability Deep Design](2026-07-28-messaging-production-capability-design.md) + +## 0. 구현 상태 + +2026-07-28 현재 구현된 범위: + +- `application-core`가 Spring annotation 없이 transaction intent를 선언하는 + `TransactionPort`; +- `REQUIRED` write/read-only와 제한적인 `REQUIRES_NEW` transaction template; +- 모든 현재 transaction mode의 명시적 `READ_COMMITTED` isolation; +- adapter-owned JPA auditing base class와 명시적 audit stamp; +- SQLState 기반 표준/PostgreSQL failure 분류 SPI와 web error carrier; +- unique scope, request fingerprint, TTL, reaper를 가진 JPA idempotency V1; +- inline response와 optional object-storage response seam; +- PostgreSQL `FOR UPDATE SKIP LOCKED`를 사용하는 polling outbox V1; +- local/JDBC 선택이 가능한 Spring Integration JDBC efficiency lock; +- Flyway V1, V3, V4, V5 production migration; +- OSIV disable 계약, datasource/Hikari/Flyway 설정 및 일부 startup validator; +- sample의 aggregate entity, optimistic version, JPQL constructor projection, + PostgreSQL integration test seam. + +그러나 다음 핵심 범위는 구현되지 않았거나 실제 production 경로에 연결되지 않았다. + +- 모든 repository/query operation을 통과하는 persistence failure translation boundary; +- constraint name 기반의 허용 목록 conflict mapping; +- commit 응답 유실과 일반 connection failure를 분리하는 commit outcome; +- application deadline과 transaction/statement/lock/pool acquisition timeout의 계층; +- transaction policy별 isolation, retry eligibility, query budget; +- primary/replica datasource, 명시적 read consistency와 lag gate; +- owner token/CAS를 가진 idempotency V2; +- immutable event와 delivery state를 분리한 owner-safe outbox V2; +- same-store inbox; +- query ID, N+1 budget, representative `EXPLAIN` plan qualification; +- PostgreSQL을 필수로 기동하는 non-skippable CI task; +- rolling schema compatibility, restore/failover rehearsal evidence; +- tenant discriminator/RLS profile; +- typed primary/replica pool capacity와 shutdown/quiesce 계약. + +따라서 이 문서에서 “설계 완료”는 구현 계약, 보장 경계, 검증 순서가 결정되었다는 뜻이다. +현재 JPA leaf나 이를 사용하는 애플리케이션이 production-ready라는 뜻이 아니다. + +### 0.1 현재 capability별 준비도 + +| Capability card | 현재 | 이 문서의 R2 목표 | 현재 판정 이유 | +| --- | --- | --- | --- | +| JPA aggregate store | production R0 / sample reference R1 | R2 | production leaf에는 목표 aggregate 구현이 없고 sample CRUD와 optimistic version만 참고 증거로 존재한다. | +| Application transaction | R1 | R2 | 세 mode와 `READ_COMMITTED`만 있으며 deadline, outcome, policy가 없다. | +| Query model | production R0 / sample reference R1 | R2 | production query card는 없고 sample projection에 bound, N+1, plan gate가 없다. | +| Flyway migration | R1 | R2 | migration 실행은 있으나 rolling/large-table/rollback evidence가 없다. | +| Polling outbox | R1 | R2 | claim은 있으나 owner-safe completion, aggregate sequence, delivery 분리가 없다. | +| JPA idempotency | R1 | R2 | scope V1이며 stale owner가 새 claim을 변경할 수 있다. | +| JDBC coordination | R1 efficiency | R2 efficiency | fencing/renewal/owner-safe release가 없으므로 correctness lock이 아니다. | +| Primary/replica routing | R0 | R2 optional | 구현과 consistency/lag evidence가 없다. | +| Same-store inbox | R0 | R2 optional | schema, port, consumer transaction choreography가 없다. | +| Tenant isolation | R0 | R2 optional | tenant key, query enforcement, RLS가 없다. | + +`R1`은 local/testable implementation evidence, `R2`는 production profile evidence, +`R3`는 실제 운영 및 복구 rehearsal evidence를 의미한다. 한 card가 R2라고 해서 leaf 전체나 +다른 card의 R2를 대신하지 않는다. + +## 1. 설계 판정 + +이 저장소에서 JPA는 범용 ORM 편의 계층이 아니라 다음 capability다. + +> application이 정의한 transaction과 repository/query port 뒤에서 aggregate state, +> same-store reliability record, schema evolution을 PostgreSQL에 안전하게 영속화하고, +> concurrency, timeout, failure, consistency의 의미를 framework-neutral 결과로 보존하는 기능 + +선택한 핵심 구조는 다음과 같다. + +1. 정확히 19개인 현재 leaf registry를 유지하고 JPA 공통 코드와 PostgreSQL 전용 코드는 + 같은 leaf의 `.postgresql` package로 격리한다. +2. `domain-core` entity와 persistence entity를 분리하며 JPA annotation을 core로 유출하지 + 않는다. +3. application에는 aggregate별 repository port, 목적별 query port, transaction policy만 + 노출한다. `JpaRepository`, `EntityManager`, `Pageable`, `Sort`, `Specification`, + Hibernate type은 노출하지 않는다. +4. aggregate command 경로는 JPA를 기본으로 하고, read model은 JPQL projection 또는 + PostgreSQL native/JDBC query를 목적별 adapter 내부 구현으로 선택한다. +5. transaction boundary는 application use case가 `TransactionPort`로 소유한다. + controller, repository adapter, mapper, scheduler가 business transaction policy를 + 새로 만들지 않는다. +6. 기존 `inWrite`, `inRead`, `inNew`는 source-compatible facade로 유지하되, + named transaction policy와 absolute `CallBudget`를 받을 수 있는 additive contract로 + 진화시킨다. +7. 기존 `inRead`는 항상 primary의 strong read다. replica는 명시적 + `ReadConsistency`와 lag qualification 없이는 사용하지 않는다. +8. optimistic concurrency를 일반 aggregate의 기본값으로 한다. pessimistic lock은 + 짧고 bounded된 indexed critical section 또는 queue claim에만 사용한다. +9. SQLState, Spring/Hibernate exception, transaction phase를 하나의 translation boundary에서 + typed failure와 retry disposition으로 변환한다. +10. connection failure가 commit 단계에 발생하면 `COMMIT_INDETERMINATE`로 분류하고 + 자동 재실행하지 않는다. stable operation ID 또는 business key로 먼저 reconcile한다. +11. Flyway가 physical schema의 유일한 writer다. production에서 Hibernate schema update, + create, create-drop은 금지한다. +12. schema 변경은 expand/bridge/backfill/switch/enforce/observe/contract로 진행하고 + 최소 N/N-1 application 호환성을 검증한다. +13. 같은 PostgreSQL transaction에 business write와 outbox/inbox/idempotency transition을 + 함께 넣을 때만 same-store atomicity를 주장한다. +14. database transaction 안에서 broker, HTTP, object storage, file server 같은 remote + side effect를 수행하지 않는다. +15. 실제 PostgreSQL, concurrency, timeout, migration, query-plan test가 필수 CI lane에서 + 통과하기 전에는 R2라고 표현하지 않는다. + +## 2. 상위 설계와 이번 심화 설계의 관계 + +상위 통합 설계는 이미 다음을 결정했다. + +- OSIV를 사용하지 않는다. +- transaction boundary는 application이 소유한다. +- Flyway migration과 failure translation을 사용한다. +- pool, timeout, batch/fetch, N+1, query plan을 운영 계약으로 다룬다. +- optimistic lock을 기본으로 하고 pessimistic lock을 제한한다. +- replica read에는 명시적 consistency가 필요하다. +- outbox, idempotency, inbox의 same-store transaction을 지원한다. +- PostgreSQL 전용 구현은 vendor package와 real-service test로 한정한다. + +이번 문서는 위 결정을 구현 계획으로 바꿀 수 있도록 다음을 추가로 고정한다. + +- 현재 코드의 실제 구현 수준과 결함; +- application transaction API의 additive evolution; +- transaction phase와 commit uncertainty; +- retry 가능 조건과 금지 조건; +- primary/replica routing 시점과 nested transaction 규칙; +- datasource, pool, admission, timeout의 산정과 validation; +- entity ID, time, enum, audit, version, relation baseline; +- query projection, fetch plan, cursor, statement budget, plan evidence; +- constraint/index/lock/tenant schema 계약; +- migration job과 application startup의 분리; +- owner-safe idempotency/outbox/inbox schema와 transaction choreography; +- 설정 activation, health, observability, security, shutdown; +- 실제 PostgreSQL CI lane과 readiness 승격 조건. + +JPA/PostgreSQL 범위에서 이 문서와 상위 문서의 요약이 충돌하면 이 문서가 더 구체적인 +정본이다. 다른 capability의 결정은 변경하지 않는다. + +### 2.1 Normative decision ledger + +| 결정 | 정본 | +| --- | --- | +| readiness와 capability card | §0, §9 | +| HARD invariant | §5 | +| 모듈/계층/package 소유권 | §7–§8 | +| application repository/query contract | §10 | +| entity와 mapping baseline | §11 | +| transaction contract/policy/propagation | §10.3–§10.4, §12 | +| isolation과 concurrency | §13 | +| locking/JDBC coordination | §14 | +| failure, retry, commit outcome | §15 | +| pool/admission/timeout | §16–§17 | +| write/batch/query/pagination | §18–§20 | +| primary/replica consistency | §21 | +| same-store idempotency/outbox/inbox | §22 | +| Flyway와 rolling migration | §23 | +| schema/index/query-plan | §24 | +| tenancy와 security | §25–§26 | +| configuration/activation/health | §27–§28 | +| observability/lifecycle/DR | §29–§30 | +| test/CI/evidence | §31 | +| Gradle/dependency/split trigger | §32 | +| 단계별 migration과 완료 기준 | §33–§34 | + +예시 코드나 YAML이 표의 정본 절과 충돌하면 정본 절을 따른다. deprecated alias와 canonical +setting이 동시에 주어지면 임의 precedence를 선택하지 않고 startup을 실패시킨다. + +## 3. 현재 코드의 증거 기반 진단 + +### 3.1 모듈 경계 + +`src/config/architecture/modules.json`은 다음을 유일한 registry로 정의한다. + +```text +id adapter-outbound-persistence-jpa +source_path src/adapter/outbound/persistence-jpa +gradle_path :adapter:outbound:persistence-jpa +allowed_dependencies + - domain-core + - application-core + - shared-contract +``` + +실제 leaf는 `application-core`, `shared-contract`, Spring Data JPA, Spring Integration JDBC, +Flyway와 PostgreSQL runtime을 소유한다. 조사 시점에 다음 HARD-STOP 위반은 발견하지 않았다. + +- `domain-core`의 Spring/JPA 의존; +- application의 inbound DTO 또는 JPA type 의존; +- controller의 repository 직접 호출; +- production leaf의 sample 의존; +- registry 밖 project dependency. + +현재 한 leaf 안에 vendor-neutral JPA와 PostgreSQL 전용 SQL이 함께 있다. 두 번째 RDBMS나 +vendor SDK의 독립 release/security boundary가 실제로 생기기 전에는 leaf를 늘리지 않는다. + +### 3.2 현행 구현과 운영 의미 + +| 영역 | 현재 구현 | 운영상 의미 | +| --- | --- | --- | +| Transaction | write/read/requires-new template, 모두 `READ_COMMITTED` | deadline, stricter isolation, phase-aware outcome이 없다. | +| Read | `inRead`가 read-only hint만 설정 | primary/replica 의미와 read consistency가 없다. | +| Failure | SQLState mapping component | production repository가 translator를 호출하지 않아 raw exception이 escape한다. | +| Mapping merge | `putAll` | 같은 SQLState의 중복 등록이 조용히 덮어써진다. | +| Audit | `AuditableEntity`와 manual stamp | adapter ownership은 맞지만 bulk DML과 update copy 규칙이 명시되지 않았다. | +| Idempotency | unique scope + status + TTL | owner token과 owner-checked CAS가 없다. | +| Outbox | 단일 event row에 claim/delivery state | immutable event와 mutable delivery가 섞이고 completion owner 검증이 없다. | +| Outbox order | timestamp 중심 | 같은 timestamp와 aggregate별 strict sequence가 정의되지 않는다. | +| Maintenance | idempotency/outbox reaper가 `@Scheduled @Transactional`을 직접 소유 | 목표 named maintenance policy와 application command 경계로 이동해야 한다. | +| Lock | Spring Integration JDBC lock | efficiency coordination이며 fencing correctness를 제공하지 않는다. | +| Migration | Flyway V1/V3/V4/V5 | rolling compatibility, large backfill, nontransactional DDL 절차가 없다. | +| Pool validation | 일부 Hikari 제약 | `5s` 같은 Duration을 parse하지 못하면 검증을 조용히 건너뛴다. | +| ORM schema | env에서 `ddl-auto` 선택 | local default `update`가 있고 production runtime guard가 충분하지 않다. | +| Query | sample projection와 paging | max size, stable ordering, N+1/plan gate가 없다. | +| Real DB test | app-bootstrap/sample의 PostgreSQL test | Docker 부재 시 assumption으로 skip될 수 있다. | +| Metrics | registry에 `db.query.duration` | 실제 bounded query recorder는 확인되지 않았다. | +| Activation | broad entity/repository scan | explicit provider/topology activation과 disabled zero-side-effect가 없다. | + +### 3.3 Failure translation은 실제 경로에 연결되지 않았다 + +`PersistenceExceptionTranslator`는 SQLState를 분류하지만 production code에서 +`translate(...)`를 호출하는 repository/query adapter를 찾을 수 없다. web handler가 +`PersistenceFailureException`을 처리해도 그 carrier가 만들어지지 않으면 계약은 성립하지 +않는다. + +목표 설계는 다음을 요구한다. + +- 모든 aggregate repository, query adapter, same-store infrastructure store가 공통 + operation executor 또는 동일한 translation rule을 통과한다. +- mapping duplicate는 startup fail-fast다. +- framework exception, SQLState, constraint name, transaction phase를 함께 본다. +- unknown failure를 성공이나 retryable로 추정하지 않는다. +- client 응답에는 SQL, SQLState, constraint/table/column 이름을 노출하지 않는다. + +### 3.4 Idempotency V1의 stale-owner 위험 + +현재 `complete`와 `discard`는 scope만으로 row를 갱신한다. 만료된 owner A 뒤 owner B가 같은 +scope를 재획득했을 때 A의 늦은 completion이 B의 claim을 변경할 수 있다. 또한 +`DataIntegrityViolationException` 전체를 claim 경쟁으로 보는 것은 다른 schema 결함을 +숨길 수 있다. + +R2에서는 다음 owner-safe transition이 필요하다. + +```text +ABSENT + -- claim(ownerToken, leaseUntil, requestHash) --> CLAIMED + +CLAIMED(owner=A) + -- markExecutionStarted(owner=A) --------------> EXECUTING(owner=A) + -- renew(owner=A) -----------------------------> CLAIMED(owner=A) + -- expire + takeover(owner=B) -----------------> CLAIMED(owner=B, attempt+1) + -- releaseBeforeExecution(owner=A) ------------> ABSENT + +EXECUTING(owner=A) + -- renew(owner=A) -----------------------------> EXECUTING(owner=A) + -- complete(owner=A, response) ----------------> COMPLETED + -- no-effect confirmed ------------------------> FAILED_RETRYABLE + -- expire/effect unknown ----------------------> ABANDONED / RECOVERY_REQUIRED + +owner=A의 늦은 renew/complete/release + ------------------------------------------------> OWNER/ATTEMPT/REVISION_MISMATCH, no mutation +``` + +만료된 `EXECUTING`은 blind takeover하지 않는다. committed/no-effect evidence를 inspect한 뒤 +reconcile complete 또는 explicit reopen만 허용한다. + +### 3.5 Outbox V1의 ownership와 ordering 위험 + +현재 claim은 PostgreSQL `SKIP LOCKED`를 사용하지만 `markPublished/Failed/Dead(eventId)`가 +claim owner와 현재 status를 검증하지 않는다. timestamp-only ordering은 aggregate 단위 +ordering을 보장하지 않으며 DEAD row가 뒤 event 진행을 막을 때의 operator 정책도 없다. + +R2에서는 다음을 분리한다. + +- `outbox_event_log_v2`: immutable business event envelope; +- `outbox_delivery_v2`: destination별 mutable claim/attempt/ack state; +- `(aggregate_type, aggregate_id, aggregate_version, event_ordinal)` unique order key; +- `claim_owner`, `claim_token`, `claim_until` owner-safe CAS; +- terminal transition의 current status + token 검증; +- operator requeue/skip/quarantine audit. + +### 3.6 현재 test evidence의 한계 + +JPA leaf focused test는 unit/mock/wiring 중심이다. PostgreSQL integration test가 다른 module에 +존재해도 Docker가 없을 때 skip되면 R2 gate가 아니다. 다음 항목은 real PostgreSQL에서 +non-skippable task로 검증해야 한다. + +- isolation anomaly와 whole-transaction retry; +- optimistic conflict, deadlock, lock/statement timeout; +- commit-uncertainty fault seam; +- pool exhaustion과 acquisition timeout; +- idempotency/outbox/inbox concurrent owner transition; +- rolling migration N/N-1 compatibility; +- index/query plan invariant; +- primary/replica routing과 lag/failover; +- backup restore와 migration forward recovery. + +## 4. 범위와 명시적 비범위 + +### 4.1 primary-only JPA R2 baseline에 포함 + +- aggregate persistence를 위한 JPA entity, mapper, Spring Data repository adapter; +- application-owned write/read/requires-new transaction; +- transaction deadline와 PostgreSQL local timeout; +- failure translation, retry disposition, commit uncertainty; +- optimistic version과 제한적 pessimistic lock; +- purpose-built query projection, fetch plan, bounded paging/cursor; +- Hikari pool capacity, acquisition timeout, admission, lifecycle; +- Flyway schema validation과 expand-contract migration; +- PostgreSQL-specific SQLState, native query, lock/claim implementation; +- typed settings, startup validation, health, metrics, traces, runbook; +- real PostgreSQL integration, concurrency, migration, plan CI. + +### 4.2 독립 승격 capability card + +다음은 primary-only JPA R2의 자동 포함 항목이 아니라 같은 leaf가 제공할 수 있는 독립 +capability card다. + +- owner-safe JPA idempotency; +- immutable event outbox storage; +- owner-safe polling delivery; +- connector checkpoint 기반 CDC retention/cleanup; +- same-store inbox; +- primary/replica routing; +- tenant discriminator/RLS; +- JDBC efficiency coordination. + +canonical optional card ID와 prerequisite는 다음과 같다. + +| Optional card ID | Prerequisite card ID | Required non-skippable task | +| --- | --- | --- | +| `jpa-idempotency-owner-safe-v2` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest` | +| `jpa-outbox-storage-v2` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest` | +| `jpa-outbox-polling-delivery-v2` | `jpa-outbox-storage-v2`, `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest` | +| `jpa-outbox-cdc-retention-v1` | `jpa-outbox-storage-v2`, `jpa-observability-lifecycle`; external `messaging-cdc-dispatch.v1` R2 | `:adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest` | +| `jpa-inbox-same-store-v1` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest` | +| `jpa-primary-replica` | `jpa-transaction-runtime`, `jpa-query-model`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest` | +| `jpa-tenant-discriminator-rls` | `jpa-primary-foundation` | `:adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest` | +| `jpa-jdbc-efficiency-coordination` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest` | + +각 card는 자기 non-skippable PostgreSQL evidence manifest가 있을 때만 별도로 R2가 된다. +한 card의 증거를 다른 card나 JPA leaf 전체의 준비도로 합산하지 않는다. base card ID와 +dependency graph, required task의 machine-readable 정본은 §31.3의 +`src/config/jpa/readiness-cards.yaml`이며 이 표와 §34는 그 projection이다. + +### 4.3 optional profile + +- primary/replica read routing; +- tenant discriminator와 defense-in-depth RLS; +- JDBC efficiency coordination; +- database-backed scheduled maintenance; +- read-model용 native/JDBC projection; +- PgBouncer 또는 managed proxy. + +optional profile도 활성화되면 해당 profile의 R2 gate를 모두 충족해야 한다. 사용하지 않는 +profile의 bean, pool, scheduler, migration이 side effect를 만들면 안 된다. + +### 4.4 이번 범위에서 제외 + +- business aggregate와 use case의 구체 설계: 목표 domain 또는 `sample-portfolio` 책임; +- controller validation, HTTP status, inbound DTO: inbound adapter 책임; +- idempotency response의 object-storage reference/finalization: 별도 cross-store response card + 책임이며 이번 `SAME_STORE_TRANSACTIONAL` R2에서 제외; +- broker publish, external notification: messaging/notification adapter 책임; +- cache/session/rate-limit: Redis 또는 해당 provider 책임; +- MongoDB query와 document schema: persistence-mongo 책임; +- generic reporting/analytics warehouse; +- XA/2PC와 cross-store exactly-once; +- arbitrary SQL console 또는 application-facing generic query language; +- database provisioning, replication orchestration, managed-service control plane; +- DBA 운영 도구 자체 구현; +- 두 번째 RDBMS 지원을 가정한 선제 module split. + +## 5. HARD invariants + +다음 중 하나라도 위반하면 기능이 동작해도 완료가 아니다. + +1. `domain-core`는 JPA, Hibernate, Spring, JDBC, SQL, database type을 import하지 않는다. +2. `application-core`는 `JpaRepository`, `EntityManager`, `Page`, `Pageable`, `Sort`, + `Specification`, persistence entity를 import하지 않는다. +3. controller는 repository, Spring Data interface, persistence entity를 직접 사용하지 않는다. +4. inbound DTO를 repository/query/application transaction contract에 전달하지 않는다. +5. entity mapper, converter, repository default method에 business invariant를 두지 않는다. +6. application use case 밖에서 business transaction boundary를 새로 만들지 않는다. +7. OSIV를 켜거나 lazy loading에 web serialization correctness를 의존하지 않는다. +8. Hibernate schema update/create를 production schema writer로 사용하지 않는다. +9. database connection을 보유한 채 broker/HTTP/object storage/file server를 호출하지 않는다. +10. replica read를 strong read 또는 read-your-writes라고 암묵적으로 표현하지 않는다. +11. `08*` connection error를 commit 여부가 확실한 일반 retryable failure로 합치지 않는다. +12. stable operation ID와 reconciliation 없이 commit-indeterminate command를 자동 재실행하지 + 않는다. +13. unique violation 전체를 idempotency claim race나 domain conflict로 간주하지 않는다. +14. application이 전달한 raw table/column/order/expression을 SQL identifier로 조합하지 않는다. +15. unbounded collection fetch, unbounded `IN`, unbounded offset/page size를 허용하지 않는다. +16. pessimistic lock 구간 안에서 remote I/O, user think time, unbounded computation을 수행하지 + 않는다. +17. efficiency JDBC lock을 fencing correctness lock으로 광고하지 않는다. +18. `REQUIRES_NEW`를 record별 loop나 동시 request마다 무제한 중첩하지 않는다. +19. migration 파일을 적용 후 수정하거나 checksum repair를 정상 배포 절차로 삼지 않는다. +20. tenant profile에서 tenant predicate 없는 query/unique/index를 허용하지 않는다. +21. 사용하지 않는 replica/maintenance/optional provider가 connection 또는 scheduler를 + 생성하지 않는다. +22. real PostgreSQL test를 조건부 skip한 결과로 R2를 주장하지 않는다. +23. SQL/parameter/PII/high-cardinality identifier를 metric tag나 일반 log에 기록하지 않는다. +24. `src/config/architecture/modules.json` 밖의 dependency edge를 설계 편의로 추가하지 않는다. + +## 6. 대안 검토 + +### A. JPA entity를 domain entity로 통합 + +boilerplate가 줄어들지만 domain이 JPA annotation, lazy proxy, collection lifecycle, +no-arg constructor, persistence identity에 결합한다. template의 가장 중요한 교체 가능성과 +HARD-STOP을 훼손하므로 선택하지 않는다. + +### B. application에 Spring Data repository를 직접 노출 + +paging과 query 작성은 빠르지만 transport/framework type이 use case contract가 된다. +aggregate별 command port와 목적별 query port를 유지한다. + +### C. 모든 query를 JPA entity graph로 해결 + +단순 조회에는 편하지만 reporting projection, keyset, PostgreSQL lock/claim, plan control에 +불리하다. aggregate write는 JPA, read model은 JPQL projection 또는 native/JDBC를 선택하는 +hybrid를 채택한다. + +### D. 모든 query를 jOOQ로 전환 + +SQL type safety와 query visibility는 장점이지만 build/code-generation, license/edition, +schema source, module dependency가 추가된다. 현재 요구 증거가 없으므로 R2 baseline에서 +도입하지 않는다. 복잡한 read model이 충분히 늘고 native query drift가 실제 비용이 될 때 +별도 설계로 검토한다. + +### E. repository adapter마다 `@Transactional` + +호출 단위는 단순해지지만 하나의 use case가 여러 port를 원자적으로 묶기 어렵고 transaction +policy가 adapter에 흩어진다. application-owned `TransactionPort`를 유지한다. + +### F. 모든 transient SQLState를 자동 retry + +재시도 편의는 생기지만 callback에 remote side effect가 있거나 commit outcome이 unknown이면 +중복 실행을 만든다. explicit replay-safe policy가 있는 whole transaction만 bounded retry한다. + +### G. read-only transaction은 자동 replica + +코드 변경이 적지만 read-after-write, lag, failover semantics가 숨겨진다. explicit +`ReadConsistency`와 route qualification을 선택한다. + +### H. `REQUIRES_NEW`로 outbox publish까지 감싸기 + +broker publish와 DB mark 사이의 원자성은 생기지 않는다. business transaction은 outbox append만 +포함하고 relay는 claim/publish/terminal transition을 분리한다. + +### I. PostgreSQL provider를 즉시 별도 leaf로 분리 + +현재는 registry 증가와 composition 복잡성만 만든다. 두 번째 RDBMS, 독립 SDK release, +security boundary가 실제로 생길 때만 split한다. + +### J. PgBouncer를 R2 필수 baseline으로 지정 + +deployment에 따라 유용하지만 transaction/session pooling mode, prepared statement, +startup parameter, failover topology가 달라진다. 별도 qualified optional profile로 둔다. + +## 7. 목표 아키텍처 + +```text +adapter:inbound:* + | + v +application use case + | - validates command semantics + | - selects named TransactionPolicyId + | - owns retry/reconciliation decision + | + +--> TransactionPort -----------------------------+ + | | + +--> AggregateRepositoryPort | + +--> PurposeBuiltQueryPort | + +--> OutboxAppendPort / IdempotencyPort / InboxPort| + v + adapter-outbound-persistence-jpa + +------------------------------+ + | transaction | + | routing | + | failure | + | aggregate/ | + | query/ | + | idempotency/outbox/inbox | + | migration configuration | + | postgresql/ | + +------------------------------+ + | | + v v + primary pool optional replica pool + | | + +------ PostgreSQL ------+ +``` + +Transaction path: + +```text +use case + -> resolve named policy + -> intersect policy timeout with CallBudget + -> resolve route before transaction begins + -> acquire admission permit + -> acquire connection / begin + -> SET LOCAL statement_timeout / lock_timeout / context + -> execute repository/query callbacks + -> flush + -> commit + -> classify phase-aware outcome + -> release connection and admission +``` + +`route`, `transaction`, `timeout`, `failure`는 repository별 임의 utility가 아니라 공통 runtime +boundary다. 그러나 application에는 하나의 generic persistence command API를 노출하지 않고 +각 feature의 semantic port를 유지한다. + +## 8. 모듈과 package 소유권 + +### 8.1 `domain-core` + +소유: + +- aggregate/entity/value object/domain event; +- invariant와 state transition; +- framework-free repository semantics가 정말 domain vocabulary일 때의 port. + +금지: + +- `@Entity`, `@MappedSuperclass`, `@Version`, `@Column`; +- `Instant`를 SQL timestamp로 변환하는 persistence 규칙; +- lazy collection, proxy, `EntityManager`; +- retry, SQLState, isolation, replica. + +### 8.2 `application-core` + +소유: + +- command와 use case; +- aggregate별 repository port; +- purpose-built query port와 framework-neutral result/cursor; +- `TransactionPort`, `TransactionPolicyId`, `ReadConsistency`, `CallBudget`; +- idempotency/outbox/inbox semantic contract; +- typed application failure와 reconciliation command. + +금지: + +- Spring transaction annotation; +- JPA entity/repository/type; +- transport DTO; +- raw SQL, table/constraint name; +- provider topology와 JDBC URL. + +### 8.3 `adapter-outbound-persistence-jpa` + +소유: + +- JPA entity, embedded ID, attribute converter; +- Spring Data repository; +- domain/persistence mapper; +- repository/query port implementation; +- transaction manager bridge와 route context; +- audit persistence metadata; +- failure translation; +- idempotency/outbox/inbox persistence implementation; +- Flyway migration; +- Hikari/JPA/PostgreSQL provider settings와 validation; +- PostgreSQL native query/SQLState/timeout/claim; +- real PostgreSQL qualification test source set. + +권장 package shape: + +```text +dev.caskeleton.adapter.outbound.persistence +├── audit +├── config +├── failure +├── routing +├── transaction +├── query +├── idempotency +├── outbox +├── inbox +├── lock +├── migration +└── postgresql + ├── config + ├── failure + ├── routing + ├── timeout + ├── query + ├── idempotency + ├── outbox + └── inbox +``` + +feature aggregate의 persistence entity/repository/mapper는 production 목표 domain이 생기면 +그 feature package에 둔다. `sample-portfolio` entity는 sample module에 남긴다. + +### 8.4 `app-bootstrap` + +소유: + +- canonical activation SSOT; +- datasource/secret material binding; +- application artifact composition; +- Flyway startup vs external migration-job mode; +- readiness/liveness exposure; +- graceful shutdown orchestration. + +business use case, repository mapping, SQL을 두지 않는다. + +### 8.5 `sample-portfolio` + +소유: + +- WorkLog sample consumer; +- template 사용 예시와 fixture; +- sample-specific entity, query adapter, migration; +- sample integration test. + +production leaf는 이 module에 의존하지 않는다. production readiness evidence가 sample에만 +존재하면 JPA leaf R2 근거로 충분하지 않다. + +## 9. Readiness와 guarantee 모델 + +### 9.1 readiness level + +| Level | 의미 | +| --- | --- | +| R0 | interface, placeholder, unqualified seam만 있다. | +| R1 | deterministic unit/local integration evidence가 있다. | +| R2 | production profile의 real PostgreSQL, failure, concurrency, migration, observability evidence가 필수 CI에서 통과한다. | +| R3 | target topology에서 restore/failover/rolling migration/capacity rehearsal와 운영 SLO evidence가 있다. | + +### 9.2 guarantee descriptor + +각 deployment는 최소 다음 descriptor를 startup log와 diagnostics에 노출한다. + +```text +provider postgresql +providerVersion 16. +ormVersion 7.1.8 +primary enabled +replica disabled | enabled +readConsistencyProfiles STRONG[, BOUNDED_STALENESS, EVENTUAL] +migrationMode STARTUP | EXTERNAL_JOB +schemaCompatibility N_AND_N_MINUS_1 +tenantMode NONE | DISCRIMINATOR | DISCRIMINATOR_RLS +transactionPolicies [COMMAND_DEFAULT, QUERY_PRIMARY, ...] +legacyWriteIdentity disabled | INVOCATION_UNCORRELATED +idempotency V1 | OWNER_SAFE_V2 +outboxStorage V1_MUTABLE | IMMUTABLE_PARTITIONED_V2 +outboxDispatchMode disabled | polling | cdc +outboxDispatchProfile disabled | POLLING_DELIVERY_V2 | CDC_RETENTION_V1 +inbox disabled | SAME_STORE_V1 +jdbcCoordination disabled | EFFICIENCY_ONLY +cardReadiness {cardId: R0 | R1 | R2 | R3} +evidenceManifestIds {cardId: immutableManifestId} +externalEvidenceIds {namespacedCardId: immutableManifestId} +migrationStreamRevisions {cardId: historyTable/revision/state} +durabilityProfile provider-qualified RPO/RTO descriptor +``` + +descriptor는 비밀, host, database/user 이름을 포함하지 않는다. configured 값이 아니라 +startup validation과 probe가 성공한 effective capability를 나타낸다. + +### 9.3 보장과 비보장 + +| 제공 가능한 보장 | 제공하지 않는 보장 | +| --- | --- | +| 한 primary DB transaction 안의 row/constraint atomicity | DB와 broker/object storage 사이 atomic commit | +| explicit version 또는 lock에 의한 lost-update 방지 | 모든 business conflict의 자동 해결 | +| policy-qualified primary read | replica의 무조건 최신 read | +| owner-safe same-store claim transition | cross-store exactly-once | +| migration checksum과 schema compatibility validation | arbitrary rollback migration의 무손실 | +| stable order key를 가진 cursor traversal | concurrent write 중 전체 dataset snapshot, 별도 transaction 없이는 보장 안 함 | +| explicit timeout의 bounded wait intent | network/driver/kernel을 포함한 완전한 hard deadline | +| commit-indeterminate typed outcome | 장애 중 commit 여부의 즉시 판정 | + +## 10. Application 계약 + +### 10.1 aggregate repository port + +repository port는 기술 CRUD가 아니라 aggregate use case에 필요한 의미를 표현한다. + +```java +public interface WorkItemRepositoryPort { + Optional findById(WorkItemId id); + void add(WorkItem aggregate); + SaveOutcome update(WorkItem aggregate, AggregateVersion expectedVersion); +} +``` + +규칙: + +- `save(T)` 하나로 insert/update/upsert를 숨기지 않는다. +- not-found, version conflict, duplicate business key를 구분한다. +- persistence-generated ID에 business 흐름이 종속되지 않도록 ID는 transaction 전 생성한다. +- returned domain object에 lazy proxy가 남지 않는다. +- repository 호출 하나가 transaction을 자동 생성한다고 가정하지 않는다. +- aggregate 밖의 대량 조회나 reporting은 별도 query port로 분리한다. + +### 10.2 purpose-built query port + +```java +public interface WorkItemSummaryQueryPort { + WorkItemSlice findSummaries( + WorkItemQuery query, + WorkItemCursor cursor, + PageLimit limit); +} +``` + +application query에는 허용된 filter/sort를 typed value로 정의한다. inbound가 전달한 arbitrary +field name, direction, expression을 그대로 받지 않는다. + +반환 타입은 다음을 포함할 수 있다. + +- immutable application projection; +- `items`; +- opaque next cursor; +- `hasNext`; +- source consistency; +- optional snapshot/as-of marker. + +Spring `Page`, `Slice`, `Sort`, `Pageable`은 adapter 내부에만 존재한다. + +read consistency 선택의 SSOT는 query port 인자가 아니라 use case가 여는 +`TransactionRequest.readConsistency`다. query port는 이미 고정된 transaction route에서 +실행하며 별도의 consistency를 받아 route를 다시 선택하지 않는다. 결과 projection에는 실제 +source consistency와 authority marker를 관측 정보로 담을 수 있지만, 이것은 입력 policy가 +아니다. + +### 10.3 transaction contract의 additive evolution + +기존 호출자는 다음 facade를 계속 사용할 수 있다. + +```java + T inWrite(Supplier action); + T inRead(Supplier action); + T inNew(Supplier action); +``` + +기존 interface에 새 abstract method를 바로 추가하면 모든 fake/provider의 source compatibility가 +깨진다. 목표 contract는 additive sub-port로 named policy를 추가한다. + +```java +public interface PolicyTransactionPort extends TransactionPort { + TransactionResult inTransaction( + TransactionRequest request, + Supplier action); +} + +public record TransactionRequest( + TransactionPolicyId policyId, + CallBudget callBudget, + Optional readConsistency, + Optional operationId) {} +``` + +정확한 class shape는 구현 계획에서 다듬을 수 있지만 다음 의미는 바꾸지 않는다. + +- caller가 임의 isolation/timeout/propagation 숫자를 전달하지 않는다. +- application이 allowlisted `TransactionPolicyId`를 선택한다. +- inbound DTO가 policy ID를 직접 고르지 않는다. +- `CallBudget`은 absolute monotonic deadline이며 wall-clock으로 serialize하지 않는다. +- command의 stable `OperationId`는 commit-indeterminate reconciliation에 사용한다. +- 기존 `inRead`는 `QUERY_PRIMARY + STRONG`이다. +- ID 인자가 없는 기존 `inWrite`/`inNew`는 각각 legacy non-replayable policy에만 연결한다. + 새 `COMMAND_DEFAULT`/`MAINTENANCE_NEW`의 operation identity를 임의 UUID로 가장하지 않는다. + +`TransactionResult`는 nullable value/exception 조합이 아니라 다음 sealed algebra와 동등해야 +한다. + +```text +COMMITTED(value, optional operationId) +PARTICIPATING_PENDING_OUTER(value) +DETERMINATE_ROLLBACK(failure) +INDETERMINATE(optional operationId, lastObservedPhase, optional reconciliationReference) +COMMITTED_WITH_POST_COMMIT_FAILURE(value, optional operationId, operationalFailure) +``` + +- final outcome은 root physical transaction owner만 만든다. +- participant는 value를 반환할 수 있지만 outer 종료 전 commit 성공을 주장하지 않는다. +- `INDETERMINATE`에는 자동 replay 권한이 없다. `PolicyTransactionPort` write policy에서는 + stable `OperationId`와 reconciliation reference가 필수이고 legacy facade에서만 둘이 없을 수 + 있다. +- `COMMITTED_WITH_POST_COMMIT_FAILURE`는 rollback failure가 아니며 value가 이미 commit된 + 결과다. semantic side effect를 `afterCommit`에 두지 않는다는 architecture rule을 전제로 + replay하지 않는다. +- `PolicyTransactionPort`의 command/infrastructure write policy는 route/admission 전에 + `operationId` 존재를 검증한다. + `OUTBOX_APPEND` participant는 outer operation identity를 상속한다. replay-safe read만 + operation ID를 생략할 수 있다. + +Spring adapter 한 instance가 `TransactionPort`와 `PolicyTransactionPort`를 함께 구현하고, +기존 read method는 canonical read policy로, 기존 write/new method는 아래 legacy-only +policy로 위임한다. 기존 fake/caller는 그대로 compile되며 +named policy가 필요한 use case만 새 sub-port로 순차 이동한다. 모든 provider/fake와 +architecture rule이 전환되기 전 기존 interface에 abstract method를 추가하거나 legacy method를 +삭제하지 않는다. + +legacy root write/new는 invocation 간 stable operation identity가 없으므로 transaction replay를 +하지 않는다. commit outcome이 indeterminate면 replay-disabled +`LegacyTransactionOutcomeIndeterminateException`과 sanitized correlation/phase만 반환하고, +operator가 allowlisted business key 또는 DB fact로 수동 reconcile한다. 별도 호출의 중복 방지를 +주장하지 않는다. outer policy transaction에 참여하면 outer operation identity를 상속한다. +호출자는 순차적으로 `PolicyTransactionPort.inTransaction(...)`으로 이동해 use-case/message/job +identity를 명시한다. 현재 `PublishPendingOutboxEventsUseCase` 같은 `inNew` 호출도 이 migration +대상이며, adapter가 생성한 random ID를 business intent와 안정적으로 연결된 ID로 취급하지 +않는다. + +`@UseCaseCapability.transactionMode`는 유스케이스의 정적 transaction shape에 대한 canonical +선언이고, `TransactionPolicyId`는 그 mode 안의 runtime refinement다. 둘의 허용 관계를 +application registry와 ArchUnit이 함께 검증한다. + +| `TransactionMode` | 허용 policy family | +| --- | --- | +| `WRITE` | REQUIRED, primary, read-write command policy | +| `READ_ONLY` | REQUIRED, read-only query policy | +| `REQUIRES_NEW` | allowlisted REQUIRES_NEW infrastructure policy | + +예를 들어 `READ_ONLY` use case가 `COMMAND_DEFAULT`를 선택하거나 `WRITE` use case가 +`QUERY_REPLICA_ELIGIBLE`을 선택하면 startup/architecture test가 실패한다. generic +`inTransaction(...)` 도입 시 기존 “`inWrite`/`inRead`/`inNew` 직접 호출” ArchUnit 규칙을 +policy-family coherence 규칙으로 함께 교체하며, 어느 한쪽만 바꿔 enforcement 공백을 만들지 +않는다. + +`@UseCaseCapability.externalOutboundAllowed=true`는 use case가 remote port를 사용할 수 있다는 +선언이지 DB transaction callback 안 remote I/O 허가가 아니다. remote call은 transaction +전후의 명시적 phase 또는 outbox/workflow로 분리한다. transaction callback이 external +provider port를 직접 호출하는 call graph는 architecture test로 거절하고, 간접 호출은 code +review와 fault test가 보강한다. + +### 10.4 named policy + +초기 canonical policy: + +| Policy | Propagation | Isolation | Read-only | Route | 허용 `ReadConsistency` | Operation ID | Retry | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `COMMAND_DEFAULT` | REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | required | none | +| `COMMAND_SERIALIZABLE_REPLAY_SAFE` | REQUIRED | SERIALIZABLE | false | PRIMARY | absent only | required | bounded whole transaction | +| `QUERY_PRIMARY` | REQUIRED | READ_COMMITTED | true | PRIMARY | `STRONG`, `READ_YOUR_WRITES` | optional | none | +| `QUERY_REPLICA_ELIGIBLE` | REQUIRED | READ_COMMITTED | true | qualified replica | `EVENTUAL`, `BOUNDED_STALENESS` | optional | query-only bounded | +| `OUTBOX_APPEND` | join caller | caller | false | PRIMARY | absent only | inherit outer | none | +| `INBOX_AND_HANDLER` | REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | required message operation | explicit message policy | +| `MAINTENANCE_NEW` | REQUIRES_NEW | READ_COMMITTED | false | PRIMARY | absent only | required batch/job operation | bounded batch only | +| `COMMAND_LEGACY_NON_REPLAYABLE` | REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | unavailable unless inherited | none | +| `MAINTENANCE_LEGACY_NON_REPLAYABLE` | REQUIRES_NEW | READ_COMMITTED | false | PRIMARY | absent only | unavailable unless inherited | none | + +read policy는 consistency가 반드시 있어야 하고 command/infrastructure policy에는 없어야 한다. +policy-consistency 조합은 route/admission 전에 resolve해 허용 표 밖이면 fail-fast한다. nested +`REQUIRED`는 이미 resolved된 outer context와 호환되는 요청만 참여하고 route/consistency를 +재선택하지 않는다. + +두 legacy policy ID는 `PolicyTransactionPort.TransactionRequest`가 선택할 수 없는 +adapter-internal compatibility entry다. descriptor는 legacy 호출 count와 +`INVOCATION_UNCORRELATED` commit-uncertainty risk를 노출하고 호출 count가 0이 된 뒤 제거한다. + +`QUERY_REPLICA_ELIGIBLE`은 replica profile이 R2가 아니면 startup에 등록하지 않는다. 등록되지 +않은 policy를 primary/default에 조용히 매핑하지 않고 configuration failure로 처리한다. + +## 11. Entity와 mapping baseline + +### 11.1 domain과 persistence entity 분리 + +```text +domain aggregate + <-> explicit mapper + <-> JPA persistence entity +``` + +mapper 책임: + +- ID/value object와 column representation 변환; +- nullable/optional representation 변환; +- persistence child collection과 domain collection 변환; +- storage enum/version compatibility 변환. + +mapper 금지: + +- status transition; +- authorization; +- price/limit/eligibility 계산; +- default business policy; +- remote lookup; +- repository 호출; +- transaction 시작. + +invariant가 깨진 row를 읽으면 조용히 보정하지 않고 typed corruption/incompatible-schema +failure로 격리한다. + +### 11.2 ID + +- application/domain에서 UUID를 먼저 생성한다. +- 외부 노출 ID와 내부 surrogate key를 분리할 필요가 있으면 명시적으로 둘 다 모델링한다. +- PostgreSQL UUID column을 기본으로 하고 string UUID 저장은 migration 호환 사유가 있을 때만 + 사용한다. +- database sequence가 필요한 high-throughput batch aggregate는 별도 benchmark/evidence 후 + 선택한다. +- ID generator 변경은 rolling compatibility migration으로 다룬다. + +### 11.3 time + +- business/audit instant는 Java `Instant`, PostgreSQL `timestamptz`를 기본으로 한다. +- JVM, JDBC, database session timezone은 UTC로 검증한다. +- local business date/time은 의미가 있을 때 `LocalDate`/`LocalTime`과 timezone ID를 + 별도로 저장한다. +- ordering에 timestamp 하나만 사용하지 않는다. 동일 timestamp tie-breaker로 stable ID 또는 + sequence를 포함한다. +- database time과 application time 중 correctness authority를 operation별로 하나만 선택한다. + +lease/claim/expiry는 database transaction 안에서 비교할 때 PostgreSQL clock을 사용한다. +domain event occurred time은 application clock port를 사용할 수 있다. + +### 11.4 enum + +- JPA ordinal enum은 금지한다. +- string code를 저장하고 rolling deploy에서 old/new version이 모두 이해하는 additive 순서를 + 따른다. +- DB check constraint를 쓰면 새 value 허용을 old application switch보다 먼저 배포한다. +- unknown future value를 무조건 기존 enum으로 강제 변환하지 않는다. read compatibility + strategy가 없으면 schema incompatibility로 fail한다. + +### 11.5 relation과 cascade + +- relation은 기본 LAZY다. +- `EAGER`를 N+1 해결책으로 사용하지 않는다. +- aggregate boundary 안의 owned child에만 cascade/orphan removal을 사용한다. +- `CascadeType.ALL`을 기본값으로 두지 않는다. +- aggregate 간 relation은 ID reference를 우선하며 하나의 거대한 object graph를 만들지 않는다. +- collection은 deterministic order가 필요하면 order column 또는 explicit key를 정의한다. + +### 11.6 optimistic version + +- mutable aggregate root에는 version을 둔다. +- application의 expected version과 persistence `@Version`을 일관되게 매핑한다. +- version conflict는 generic internal error가 아니라 typed concurrent modification이다. +- conflict 후 자동 merge는 domain policy가 명시한 경우에만 한다. +- bulk update/delete는 JPA version과 persistence context를 우회하므로 일반 aggregate + command에 사용하지 않는다. + +### 11.7 audit + +- audit actor/request context는 adapter의 `AuditContextPort`에서 받는다. +- create/update stamp는 persistence entity mapping의 기술 정보다. +- business event의 actor/reason은 domain/application command에 별도로 남긴다. +- bulk/native DML은 audit/version을 자동 적용하지 않으므로 별도 명시 SQL과 test가 필요하다. +- update 시 기존 creation audit을 보존하기 위해 불필요한 추가 read를 강제하지 않도록 + persistence context와 mapping strategy를 설계한다. +- actor가 없을 때 `system` fallback을 허용하는 operation 목록을 명시한다. + +### 11.8 column baseline + +- 금액은 scale/precision이 명시된 decimal 또는 smallest-unit integer다. +- JSON은 schema/version/size/query requirement가 있을 때만 사용한다. +- large binary는 DB가 correctness/transaction boundary여야 하는 작은 payload에만 사용하고, + 일반 object는 object-storage reference를 사용한다. +- nullable column은 migration compatibility와 domain optionality를 구분한다. +- natural/business key에는 명시적 unique constraint name을 부여한다. +- 모든 FK/index/constraint 이름은 deterministic naming convention을 사용한다. + +## 12. Transaction semantics + +### 12.1 transaction boundary + +권장 command shape: + +```text +use case + validate pure input + derive stable operationId from the application command identity + -> transactionPort.inTransaction(COMMAND_DEFAULT, budget, operationId) { + load aggregate + apply domain transition + persist aggregate + append outbox + } + -> return application result +``` + +금지 shape: + +```text +controller @Transactional +repository adapter @Transactional +mapper starts transaction +transaction { + write DB + call broker/HTTP/object storage +} +``` + +Spring scheduler가 maintenance trigger를 소유할 수는 있으나 business policy와 transaction +selection은 application command를 호출해야 한다. 순수 infrastructure reaper도 명시적인 +maintenance policy와 bounded batch를 가져야 한다. + +### 12.2 propagation + +기본은 `REQUIRED`다. + +- command 내 여러 repository 호출은 하나의 physical transaction에 참여한다. +- nested `REQUIRED`가 rollback-only가 되면 outer caller에게 명확히 실패한다. +- `NESTED` savepoint는 R2 baseline에서 제공하지 않는다. +- `NOT_SUPPORTED`, `NEVER`, `MANDATORY`를 application-facing generic option으로 노출하지 + 않는다. +- `REQUIRES_NEW`는 outbox/audit/compensation이라는 이름만으로 자동 허용하지 않는다. + caller transaction과 독립 commit이 실제 invariant인지 검토한다. + +`REQUIRES_NEW` pool capacity 하한: + +```text +required connections + >= max concurrent outer transactions + + max concurrent REQUIRES_NEW transactions + + maintenance/migration/health reserve +``` + +한 outer transaction이 동시에 하나의 inner transaction만 열어도 각 active outer connection이 +반납되지 않는다. record별 `REQUIRES_NEW` loop는 금지하고 bounded batch transaction을 사용한다. + +R2 baseline은 `REQUIRES_NEW` 최대 중첩을 1로 제한하고 outer lane과 분리된 inner +connection/permit reserve를 둔다. outer가 primary connection과 permit을 보유한 채 일반 +command lane의 permit을 다시 기다리는 순환은 금지한다. acquire 순서는 +`outer permit -> outer connection -> inner-reserve permit -> inner connection`으로 고정하며, +inner reserve가 없으면 outer transaction을 시작하기 전에 해당 policy를 거절한다. capacity +test는 모든 outer가 동시에 inner를 요구하는 barrier scenario에서도 유한 시간 안에 진행하거나 +명시적으로 admission reject하는지 검증한다. + +### 12.3 read-only + +read-only는 다음을 의미한다. + +- transaction intent와 ORM flush optimization; +- primary/replica route eligibility의 한 입력; +- PostgreSQL read-only transaction 설정 검증. + +다음을 의미하지 않는다. + +- replica 자동 사용; +- stale read 허용; +- database가 모든 accidental write를 항상 막는다는 무조건 보장; +- transaction 없이 lazy load 허용. + +### 12.4 flush + +- normal command는 commit 직전 flush에 의존할 수 있다. +- constraint/version failure를 특정 application step에서 분류해야 하면 그 step 뒤에 explicit + flush한다. +- explicit flush는 commit 성공을 의미하지 않는다. +- bulk loop는 batch마다 flush/clear하고 detached entity를 domain result로 반환하지 않는다. +- query-before-commit의 implicit flush 비용을 query design에 포함한다. + +### 12.5 checked exception과 rollback + +application callback은 현재 `Supplier`/`Runnable` 기반 RuntimeException contract를 유지한다. +checked failure가 필요한 port는 application typed RuntimeException carrier로 감싸며 원인을 +보존한다. 임의 `catch (Exception)` 후 성공 결과를 반환하지 않는다. + +rollback failure가 원래 action failure를 대체할 수 있으므로 transaction outcome에는 primary +failure와 cleanup/rollback failure를 함께 보존한다. client에는 하나의 안전한 error code만 +노출한다. + +## 13. Isolation과 concurrency + +### 13.1 기본 isolation + +PostgreSQL의 `READ_COMMITTED`를 일반 command/query 기본값으로 유지한다. 각 statement는 +statement 시작 시점의 snapshot을 볼 수 있으므로 한 transaction 안의 두 query가 다른 +committed state를 볼 수 있음을 문서화한다. + +`READ_COMMITTED`로 충분한 경우: + +- primary key로 aggregate를 읽고 `@Version`으로 update conflict를 검출; +- unique/check/FK constraint가 correctness를 최종 보장; +- queue claim이 single statement 또는 lock-protected transition; +- read-only projection이 repeatable snapshot을 요구하지 않음. + +### 13.2 REPEATABLE_READ + +다음 경우 named policy로만 사용한다. + +- 한 transaction 내 여러 query가 동일 snapshot을 봐야 하는 export/snapshot 계산; +- write skew가 DB constraint/optimistic version으로 방지되는지 별도 검토된 경우. + +long-running snapshot은 vacuum과 replica replay를 방해할 수 있으므로 row/time budget, +statement timeout, 운영 관측을 필수로 한다. + +### 13.3 SERIALIZABLE + +다음 조건을 모두 만족할 때 사용한다. + +- business invariant를 constraint나 single-row version만으로 표현하기 어렵다. +- transaction callback 전체가 replay-safe다. +- serialization failure 시 transaction 전체를 처음부터 재실행한다. +- max attempts, jitter, absolute budget이 있다. +- 외부 side effect가 callback 안에 없다. + +serialization failure 하나의 statement만 재시도하지 않는다. 이전 read에 의존한 모든 판단을 +다시 수행한다. + +### 13.4 database constraint가 최종 correctness authority + +“먼저 조회한 뒤 없으면 insert”만으로 uniqueness를 보장하지 않는다. 다음을 사용한다. + +- named unique constraint; +- check constraint; +- FK; +- exclusion constraint가 실제 interval conflict에 필요하면 PostgreSQL-specific migration; +- atomic conditional `UPDATE ... WHERE ...`; +- version predicate. + +application pre-check는 친절한 메시지나 빠른 거절을 위한 optimization일 뿐 race correctness가 +아니다. + +### 13.5 conflict 결과 + +| 원인 | application 의미 | 기본 retry | +| --- | --- | --- | +| optimistic version mismatch | concurrent modification | 없음; caller/domain policy | +| allowlisted business unique constraint | duplicate/conflict | 없음 | +| serialization `40001` | replay-safe transaction conflict | bounded whole transaction | +| deadlock `40P01` | lock ordering/runtime conflict | replay-safe일 때만 bounded | +| lock timeout `55P03` | contention timeout | 기본 없음 | +| statement cancel/timeout `57014` | deadline/resource | budget이 남고 query-only일 때만 | + +## 14. Locking + +### 14.1 optimistic locking이 기본 + +일반 aggregate update: + +```text +read aggregate + version +apply domain transition +UPDATE ... WHERE id = ? AND version = ? +affected rows == 1 -> success +affected rows == 0 -> conflict/not-found distinction +``` + +장점: + +- connection을 보유한 대기 시간이 짧다. +- application이 conflict 의미를 결정할 수 있다. +- cluster node 수와 무관하게 DB row version이 authority다. + +### 14.2 pessimistic lock 허용 조건 + +다음을 모두 만족해야 한다. + +- lock target을 index로 빠르게 찾는다. +- transaction이 짧고 remote I/O가 없다. +- deterministic lock order가 있다. +- `lock_timeout`이 finite다. +- max rows가 bounded다. +- timeout/conflict가 typed outcome이다. +- real PostgreSQL concurrency test가 있다. + +`PESSIMISTIC_WRITE` 또는 `SELECT ... FOR UPDATE`는 해당 query method에 명시한다. repository +전체에 broad default를 적용하지 않는다. + +### 14.3 lock ordering + +여러 row/aggregate를 잠글 때 stable key ascending 같은 단일 order를 정의한다. 서로 다른 +feature가 같은 table을 잠그면 공유 lock-order 문서와 test를 갖는다. + +deadlock은 완전히 제거할 수 있다고 주장하지 않는다. `40P01`을 관측하고 replay-safe +transaction에만 bounded retry한다. + +### 14.4 `SKIP LOCKED` + +`SKIP LOCKED`는 queue-like work claim에만 사용한다. + +- 일반 사용자 조회에 사용하지 않는다. +- 결과가 일관된 snapshot이나 모든 row를 포함한다고 주장하지 않는다. +- deterministic eligibility/order와 batch limit가 필요하다. +- claim 후 owner token/lease가 별도 row state에 기록되어야 한다. +- starvation, DEAD head, reaper 정책을 운영 지표로 관측한다. + +### 14.5 advisory lock + +PostgreSQL advisory lock은 R2 baseline에서 사용하지 않는다. 도입 시: + +- session vs transaction scope; +- key collision; +- connection pool 반환; +- failover; +- fencing 부재; +- observability + +를 별도 설계한다. schema migration serialization은 Flyway의 지원 계약을 우선한다. + +### 14.6 JDBC distributed lock의 한계 + +현행 Spring Integration JDBC lock은 다음 용도만 허용한다. + +- duplicate scheduler work를 줄이는 efficiency coordination; +- 재실행 가능한 maintenance batch; +- correctness가 DB constraint/CAS로 별도 보호되는 작업. + +다음을 보장하지 않는다. + +- stale worker write 차단; +- fencing token; +- exactly-once; +- remote resource ownership; +- lease renewal 중 network partition safety. + +release는 owner-safe하고 idempotent한 결과로 진화해야 하며 interruption과 timeout을 구분한다. +correctness가 필요하면 resource write가 fencing token을 검증하는 별도 contract를 사용한다. + +## 15. Failure, retry와 commit outcome + +### 15.1 하나의 translation boundary + +모든 persistence operation은 다음 boundary를 통과한다. + +```text +framework exception + + SQLException chain / SQLState + + constraint name + + transaction phase + + operation kind + -> PersistenceFailure + -> application-safe error + retry disposition + reconciliation requirement +``` + +권장 internal shape: + +```java +record PersistenceFailure( + PersistenceFailureCode code, + RetryDisposition retry, + TransactionOutcome outcome, + String operationId, + Throwable cause) {} +``` + +application-facing contract가 이 exact record를 가져야 한다는 뜻은 아니다. 중요한 것은 +분류 정보가 generic `INTERNAL_ERROR` 하나로 소실되지 않는 것이다. + +### 15.2 transaction phase + +transaction manager 주변 collaborator는 최소 다음 상태를 기록한다. + +```text +ROUTE_ADMISSION +-> CONNECTION_ACQUIRED +-> ACTIVE +-> FLUSHED +-> COMMIT_REQUESTED +-> COMMIT_ACKED +-> SYNCHRONIZATION_CLEANUP +``` + +| Phase | failure 의미 | DB effect | +| --- | --- | --- | +| route/admission 전 | 실행 안 됨 | 없음 | +| connection acquire/begin | transaction 시작 실패 | 없음으로 판정 가능해야 함 | +| active action/flush | statement, mapping, constraint 실패 | rollback 확인 시 determinate rollback | +| `COMMIT_REQUESTED`, ACK 없음 | commit 요청/응답 중 연결 유실 | `COMMIT_INDETERMINATE` 가능 | +| `COMMIT_ACKED` 뒤 synchronization/cleanup | DB commit은 확인됐으나 후처리 실패 | committed + post-commit failure, replay 금지 | +| rollback | cleanup 실패 | 원래 failure와 함께 운영 escalation | + +classification precedence는 단순히 “commit method가 예외를 던졌다”가 아니다. + +1. `40001`, `40P01`, rollback-only 또는 `UnexpectedRollbackException`이고 resource rollback이 + 확인되면 determinate rollback이다. +2. commit 요청 뒤 `08007`, connection loss, socket timeout이 발생했고 ACK를 확인하지 못하면 + indeterminate다. +3. JDBC commit ACK 뒤 transaction synchronization 또는 resource cleanup이 실패하면 + committed post-commit failure다. callback을 재실행하지 않는다. +4. phase를 관측하지 못하면 더 안전한 unknown/indeterminate로 강등한다. + +`08*` connection class를 어느 phase에서든 같은 `DB_UNAVAILABLE`로만 반환하면 commit +uncertainty를 잃는다. 각 상태의 resource-level fault injection seam이 있어야 하며, +framework exception class보다 실제 phase/outcome 증거가 우선한다. + +#### 선택한 Spring 관측 지점 + +JPA leaf의 정본 구현 방향은 `PhaseAwareTransactionExecutor`가 +`PlatformTransactionManager` decorator와 가장 먼저 실행되는 ordered +`TransactionSynchronization` sentinel을 함께 사용하는 것이다. + +- decorator는 delegate `getTransaction`, `commit`, `rollback` 호출 전후의 phase를 기록한다. +- `TransactionStatus.isNewTransaction()`이 true인 physical owner만 final + `COMMITTED/ROLLED_BACK/UNKNOWN`을 판정한다. +- 기존 outer transaction에 참여한 `REQUIRED` boundary는 정상 반환을 + `PARTICIPATING_PENDING_OUTER`로 기록하며 committed outcome을 노출하지 않는다. 최종 결과는 + outer physical owner가 결정한다. +- phase tracker와 sentinel은 logical method call마다가 아니라 physical transaction identity별 + 하나다. `REQUIRES_NEW`는 outer tracker를 suspend하고 독립 tracker/outcome을 만든 뒤 outer를 + resume한다. +- sentinel `afterCommit` 진입은 physical commit 뒤의 `COMMIT_ACKED` 관측으로 사용한다. +- `afterCompletion(STATUS_COMMITTED)`는 `COMMITTED`, + `STATUS_ROLLED_BACK`은 `ROLLED_BACK`, `STATUS_UNKNOWN`은 `UNKNOWN`이다. +- physical owner의 delegate `commit()`이 정상 반환하면 `COMMITTED`다. participant의 + `commit()` 정상 반환은 physical commit 증거가 아니다. +- delegate `commit()`이 예외를 던져도 sentinel이 commit ACK/committed를 이미 관측했다면 + `COMMITTED_WITH_POST_COMMIT_FAILURE`다. +- rollback-only/flush failure 뒤 `STATUS_ROLLED_BACK`이면 determinate rollback이다. +- commit 요청 뒤 ACK와 completion status가 모두 없으면 `UNKNOWN`이며 + `DB_COMMIT_INDETERMINATE`로만 번역한다. + +sentinel은 business callback을 실행하지 않고 상태만 기록한다. user-defined +`afterCommit`/`afterCompletion` callback의 실패가 이 관측을 가리지 않도록 ordering을 +고정한다. Spring/JPA 버전 변경 때 이 ordering과 callback lifecycle을 integration test로 +재검증한다. 이 seam을 우회해 raw `TransactionTemplate`을 production에 별도 생성하지 않는다. +integration test는 inner `REQUIRED` 정상 반환 뒤 outer rollback, inner 정상 반환 뒤 outer +commit ACK 유실, `REQUIRES_NEW`의 독립 physical outcome을 구분한다. + +legacy `inWrite/inRead/inNew` facade는 기존처럼 value를 반환하며 참여 boundary에서 final commit +성공을 새로 노출하지 않는다. 결과 매핑은 다음으로 고정한다. + +| Physical result | `PolicyTransactionPort` | legacy facade | +| --- | --- | --- | +| `COMMITTED` | committed result + value | value 반환 | +| `PARTICIPATING_PENDING_OUTER` | pending result + value | value 반환, commit 보장 없음 | +| `DETERMINATE_ROLLBACK` | typed rollback result | 기존 translated persistence exception | +| `INDETERMINATE` | operation/reconciliation을 포함한 indeterminate result | replay-disabled typed exception; legacy root면 operation reference 없음 | +| `COMMITTED_WITH_POST_COMMIT_FAILURE` | committed value와 operational failure를 함께 반환 | value 반환 + mandatory incident metric/trace/readiness degradation | + +legacy에서 post-commit failure를 rollback/retryable exception처럼 던지지 않는다. 실패한 +callback은 observation/resource cleanup만 허용하며, semantic callback은 startup architecture +검증에서 거절하고 outbox/workflow로 옮긴다. cleanup 실패 connection은 폐기하고 incident를 +운영자가 확인할 때까지 readiness policy에 반영한다. `UNKNOWN` completion은 commit 요청 이후면 +`INDETERMINATE(DB_COMMIT_INDETERMINATE)`, commit 요청 전 rollback/resource discard도 확인하지 +못했으면 `INDETERMINATE(DB_TRANSACTION_OUTCOME_UNKNOWN)`으로 정규화한다. 둘 다 operation +ledger/reconciliation 전에는 facade callback을 재실행하지 않는다. + +### 15.3 internal failure code와 public error compatibility + +다음은 adapter/application 내부 `PersistenceFailureCode`의 최소 분류다. 곧바로 +`shared-contract.OperationalError`의 public 이름을 교체한다는 뜻이 아니다. + +| Code | 대표 근거 | 의미 | +| --- | --- | --- | +| `DB_UNAVAILABLE` | acquire/begin의 `08*`, resource failure | 실행 전 또는 확실한 rollback 후 unavailable | +| `DB_COMMIT_INDETERMINATE` | commit 중 `08007` 또는 connection loss | reconcile before retry | +| `DB_TRANSACTION_OUTCOME_UNKNOWN` | commit 전 rollback/resource discard도 확인 불가 | reconcile/fatal cleanup, no blind retry | +| `DB_POST_COMMIT_FAILURE` | commit ACK 뒤 synchronization/cleanup failure | committed, replay 금지, 운영 복구 | +| `DB_SERIALIZATION_FAILURE` | `40001` | whole transaction replay 후보 | +| `DB_DEADLOCK` | `40P01` | replay-safe일 때 후보 | +| `DB_CONSTRAINT_VIOLATION` | `23*` | allowlist로 세분화하지 못한 constraint | +| `DB_UNIQUE_VIOLATION` | `23505` | allowlist가 application semantic conflict로 번역할 수 있음 | +| `DB_FK_VIOLATION` | `23503` | referenced state conflict | +| `DB_NULL_VIOLATION` | `23502` | schema/data contract defect 또는 invalid input | +| `DB_CHECK_VIOLATION` | `23514` | invariant/schema conflict | +| `DB_READ_ONLY` | `25006` | route/role/config drift | +| `DB_IDLE_TRANSACTION_TIMEOUT` | `25P03` | idle transaction operational guard | +| `DB_LOCK_TIMEOUT` | `55P03` | bounded contention | +| `DB_QUERY_TIMEOUT` | `57014` + server timeout marker | statement timeout | +| `DB_QUERY_CANCELLED` | `57014` + caller cancel marker | explicit cancellation | +| `DB_STATEMENT_INDETERMINATE` | `40003` | statement completion unknown, reconcile/no blind retry | +| `DB_RESOURCE_EXHAUSTED` | `53*` | PostgreSQL resource capacity | +| `ADMISSION_REJECTED` | local admission | DB operation 시작 전 lane 거절 | +| `POOL_ACQUISITION_TIMEOUT` | Hikari wait | pool wait timeout, statement 미실행 | +| `DB_CONNECT_TIMEOUT` | login/connect bootstrap | physical connection 생성 실패 | +| `DB_OPTIMISTIC_CONFLICT` | ORM optimistic exception | concurrent aggregate update | +| `DB_PESSIMISTIC_CONFLICT` | ORM lock exception | lock acquisition failure | +| `DB_SCHEMA_INCOMPATIBLE` | missing relation/column/type | deploy/migration mismatch | +| `DB_UNKNOWN` | unmapped | retry false, secure diagnostic | + +SQLState exact mapping이 중복되면 startup을 실패시킨다. broad class mapping보다 exact mapping이 +우선하되, priority를 암묵적인 bean order로 정하지 않는다. + +`57014` 하나만 보고 timeout과 caller cancellation을 추정하지 않는다. executor가 설치한 +server timeout과 explicit cancellation token/statement cancel 관측을 함께 사용하며, 구분할 +증거가 없으면 더 좁은 자동 retry 권한을 부여하지 않는다. + +현재 public registry의 다음 아홉 이름은 동결한다. + +```text +DB_UNAVAILABLE +DB_SERIALIZATION_FAILURE +DB_DEADLOCK +DB_NULL_VIOLATION +DB_FK_VIOLATION +DB_UNIQUE_VIOLATION +DB_CHECK_VIOLATION +DB_IDLE_IN_TX_TIMEOUT +DB_QUERY_CANCELED +``` + +internal/public 호환 mapping은 명시적 registry로 관리한다. + +| Internal | Existing public | +| --- | --- | +| `DB_UNAVAILABLE` | `DB_UNAVAILABLE` | +| `DB_SERIALIZATION_FAILURE` | `DB_SERIALIZATION_FAILURE` | +| `DB_DEADLOCK` | `DB_DEADLOCK` | +| `DB_NULL_VIOLATION` | `DB_NULL_VIOLATION` | +| `DB_FK_VIOLATION` | `DB_FK_VIOLATION` | +| `DB_UNIQUE_VIOLATION` | `DB_UNIQUE_VIOLATION` | +| `DB_CHECK_VIOLATION` | `DB_CHECK_VIOLATION` | +| `DB_IDLE_TRANSACTION_TIMEOUT` | `DB_IDLE_IN_TX_TIMEOUT` | +| `DB_QUERY_TIMEOUT`, `DB_QUERY_CANCELLED` | `DB_QUERY_CANCELED` | + +`DB_COMMIT_INDETERMINATE`, `DB_TRANSACTION_OUTCOME_UNKNOWN`을 포함한 새 internal outcome은 +registry migration 전에 기존 `DB_UNAVAILABLE`로 뭉개거나 public code로 직접 노출하지 않는다. +해당 target result를 production에 활성화하기 전에 `docs/registries/error-codes.yaml`, +`OperationalError`, web mapping, category/HTTP/retryable contract와 consumer compatibility를 +하나의 migration으로 변경한다. indeterminate public code는 `retryable=false`이고 +stable operation identity가 있는 policy는 reconciliation reference를 별도 안전한 response +field/header로 전달한다. legacy root는 reference를 만들지 않고 +`reconciliationAvailable=false`만 노출한다. 어느 쪽도 raw DB 정보를 포함하지 않는다. +allowlisted constraint의 business 의미는 application error로 번역하며 +skeleton-wide `shared-contract`에 `DUPLICATE` 같은 domain vocabulary를 추가하지 않는다. + +public error의 기존 `retryable=true`는 transport/client advisory일 뿐 +`SAFE_WHOLE_TRANSACTION` 허가가 아니다. 자동 transaction replay는 §15.5의 별도 disposition과 +operation ledger 조건을 모두 충족해야 한다. `57014`를 재시도하더라도 같은 transaction의 +statement만 반복하지 않고 결과가 노출되지 않은 replay-safe whole read transaction을 새로 +시작한다. + +### 15.4 constraint allowlist + +constraint name은 adapter 내부 registry에서 semantic error로 매핑한다. + +```text +uk_work_item_external_key -> WORK_ITEM_ALREADY_EXISTS +fk_work_item_owner -> WORK_ITEM_OWNER_MISSING +ck_work_item_status -> PERSISTED_STATE_INVALID +``` + +규칙: + +- allowlist에 없는 `23505`를 idempotency race로 취급하지 않는다. +- DB constraint name을 client message에 노출하지 않는다. +- rename migration은 old/new name을 rolling window 동안 모두 인식한다. +- mapping coverage를 migration/test가 검증한다. + +### 15.5 retry disposition + +```text +NEVER +SAFE_WHOLE_TRANSACTION +RECONCILE_FIRST +CALLER_POLICY +``` + +retry 조건: + +- transaction policy가 replay-safe를 선언한다. +- callback에 remote side effect가 없다. +- ID, clock/random 결과가 retry 간 안정적이거나 command intent에 고정된다. +- absolute `CallBudget`이 남아 있다. +- attempts와 exponential backoff/jitter가 bounded다. +- commit outcome이 determinate rollback이다. + +금지: + +- commit-indeterminate 자동 retry; +- controller/filter의 blanket retry; +- statement 하나만 재시도; +- 모든 `DataIntegrityViolationException` retry; +- 이미 소비한 one-shot stream callback retry; +- 새 operation ID를 생성한 재시도. + +### 15.6 commit-indeterminate reconciliation + +replay 가능한 command는 stable `OperationId`와 canonical `intentDigest`를 transaction의 첫 +write로 operation ledger에 기록한다. `operation_id`는 unique이고 같은 ID의 다른 digest는 +conflict다. 원 transaction과 replay가 겹치면 이 unique row/lock이 database 안에서 둘을 +중재한다. + +```text +operation_ledger + operation_id primary key + operation_catalog_id + intent_digest + source_revision? + result_digest? + committed_at +``` + +raw request나 response를 ledger에 복제하지 않는다. 성공 복원에 필요한 bounded receipt만 +저장하고 retention은 idempotency/retry window보다 짧지 않게 한다. + +```text +commit response lost + -> return typed INDETERMINATE + -> inspect current writable authority by operation ID + -> if committed intent matches: restore success + -> if conflicting intent: application/operator conflict + -> if absent but authority/timeline/RPO is not qualified: remain indeterminate + -> if same authoritative timeline is qualified and replay is allowed: + retry the same operation ID + intent digest through the unique ledger + (the database, not an absent read, arbitrates) + -> if conflicting intent: operator/application conflict + -> if still unknown: remain indeterminate +``` + +“connection error이므로 실패했다” 또는 “retry 후 성공했으므로 한 번만 실행됐다”라고 +추론하지 않는다. 한 번의 primary absent read는 original commit이 아직 진행 중이거나 +topology가 전환 중일 수 있으므로 safe retry 증거가 아니다. reconciliation은 current writable +role, authority/timeline epoch, observation horizon, declared RPO를 함께 검증한다. failover가 +acknowledged write를 잃을 수 있는 window라면 absent여도 `INDETERMINATE`를 유지한다. +operation ledger가 없거나 operation key로 duplicate safety를 증명하지 못하는 command도 자동 +replay하지 않는다. legacy facade root write는 이 범주이며 sanitized correlation과 allowlisted +business key를 이용한 manual inspection만 가능하다. invocation 뒤 새 operation ID를 만들어 +ledger에 소급 삽입하거나 별도 재요청을 같은 operation으로 가장하지 않는다. + +## 16. Datasource, pool과 admission + +### 16.1 pool은 deployment-wide budget이다 + +pool size는 한 pod의 성능 숫자가 아니라 각 PostgreSQL server/proxy의 실제 application +connection budget에서 역산한다. primary와 서로 다른 replica의 `max_connections`를 하나의 +합계로 더하지 않는다. + +```text +usable_application_connections(server) + = configured_server_limit + - superuser/provider reserved slots + - platform agents and monitoring + - migration/admin/break-glass reserve + - failover safety reserve + +required_connections(server, topology_state) + = sum(maximum pools that can target server in topology_state) + + health/maintenance reserve + +max(required_connections(server, every qualified topology_state)) + <= usable_application_connections(server) +``` + +`topology_state`에는 HPA 최대 instance, rolling surge, blue/green overlap, replica promotion, +replica query의 primary fallback을 포함한다. primary, 각 replica, proxy quota마다 별도 표를 +남긴다. 현재 pod 수나 정상 상태 하나만 사용하지 않는다. + +현행 D12의 per-process 하한도 보존한다. + +```text +hikari.maximumPoolSize + >= concurrent_threads * (1 + max_inNew_depth) + 1 +``` + +추가 health/maintenance reserve가 1보다 크면 별도 합산한다. R2 baseline의 +`max_inNew_depth`는 1이고 §12.2의 전용 inner reserve를 포함한다. 이 local 하한과 server별 +deployment 상한을 동시에 만족하지 못하면 concurrency를 낮추거나 `REQUIRES_NEW` 구조를 +제거해야지 pool 설정을 강제로 통과시키지 않는다. + +### 16.2 primary와 replica pool 분리 + +replica profile은 route와 관측을 위해 별도 datasource/pool을 사용한다. + +| Pool | 역할 | minimum idle | maximum | 필수 role probe | +| --- | --- | --- | --- | --- | +| primary | write, strong read, reconciliation | explicit | capacity-derived | writable primary | +| replica | eventual/bounded read only | explicit | capacity-derived | read-only standby/qualified endpoint | + +한 JDBC URL의 multi-host failover 기능만으로 semantic primary/replica routing을 대신하지 않는다. +primary pool은 `targetServerType=primary`와 role probe로 writable endpoint를 검증한다. +replica pool은 exact secondary/read-only endpoint를 요구한다. `preferSecondary`처럼 primary로 +조용히 fallback하는 설정은 replica consistency descriptor와 충돌하므로 사용하지 않는다. + +router가 replica failure 시 primary로 fallback할 수 있는 policy는 별도로 이름 붙인다. +fallback은 consistency를 강화하지만 primary load를 증가시키므로 metric과 admission을 거친다. +fallback은 transaction 시작 전 qualification 실패 때만 바로 허용한다. transaction/query가 +시작된 뒤에는 같은 transaction에서 route를 바꾸지 않는다. 새 primary transaction에서 전체 +query를 replay하는 fallback은 결과가 한 row도 caller에 노출되지 않았고 query가 +replay-safe/materialized인 경우에만 허용한다. streaming 또는 일부 row 소비 뒤 failure는 typed +`CONSISTENCY_UNAVAILABLE`/`DB_UNAVAILABLE`로 끝내며 중간부터 이어 읽지 않는다. + +### 16.3 fixed-size와 minimum idle + +Hikari의 fixed-size 권장은 capacity가 산정된 production profile에서만 적용한다. + +```text +minimumIdle == maximumPoolSize +``` + +를 선택하면 startup/warmup connection storm, failover, rolling deploy의 총 연결 수를 검증한다. +elastic pool을 선택하면 minimum, idle timeout, cold acquisition SLO를 별도로 검증한다. + +template은 모든 환경에 하나의 숫자를 강제하지 않는다. 대신 다음을 강제한다. + +- 모든 pool shape 값이 explicit; +- deployment-wide capacity equation; +- invalid/ambiguous Duration fail-fast; +- primary/replica별 metric; +- load evidence와 운영 owner. + +### 16.4 virtual thread와 admission + +Java 21 virtual thread는 JDBC connection 수를 늘리지 않는다. 많은 request가 작은 pool 앞에 +동시에 대기하면 memory와 tail latency가 커진다. + +각 DB operation class에 application-level admission/bulkhead를 둔다. + +```text +accepted concurrency + <= pool capacity + bounded wait queue +``` + +admission acquire는 `CallBudget`을 사용하며 connection을 얻기 전에 실패할 수 있다. +command/query/maintenance가 같은 permit을 무제한 경쟁하지 않도록 lane 또는 reserve를 둔다. +health probe가 request pool을 고갈시키지 않게 한다. + +각 named policy는 `admissionBudget`, `acquireBudget`, `beginBudget`, +`minimumActionWindow`, `completionMargin`을 갖는다. Hikari `connectionTimeout`은 pool 전역 +설정이므로 request마다 mutate하지 않는다. 한 pool을 공유하는 모든 수용 policy에 대해: + +```text +connectionTimeout <= policy.acquireBudget +``` + +를 startup에 검증한다. 더 짧은 acquisition class가 필요하면 별도 capacity가 산정된 pool을 +만들거나 보수적으로 fail-fast하며, 같은 pool의 전역 timeout을 동적으로 바꾸지 않는다. + +### 16.5 connection lifecycle + +필수 설정과 조건: + +- `connectionTimeout`은 Hikari가 허용하는 finite 값이며 minimum보다 작지 않다. +- `validationTimeout < connectionTimeout`. +- `keepaliveTime < maxLifetime`. +- `maxLifetime`은 infrastructure connection lifetime보다 충분히 짧고 jitter를 고려한다. +- `idleTimeout`은 elastic pool일 때만 의미가 있다. +- leak detection은 진단 도구이며 correctness나 timeout 대체가 아니다. +- initialization fail timeout과 startup retry 정책을 명시한다. +- JDBC login/connect/socket timeout도 bootstrap budget 안에 둔다. +- `connectionTimeout`은 그 pool을 사용하는 모든 policy의 최소 acquisition budget 이하이다. +- admission 뒤 남은 budget이 worst-case pool wait와 begin/action/completion 최소 window를 + 담지 못하면 `getConnection()` 전에 거절한다. + +Duration parser가 `5s`, ISO-8601, millisecond 중 canonical format을 정확히 읽지 못하면 값을 +무시하지 않고 startup을 실패시킨다. + +### 16.6 pool exhaustion outcome + +pool acquisition timeout은 query timeout이나 DB unavailable과 구분한다. + +```text +ADMISSION_REJECTED application lane capacity +POOL_ACQUISITION_TIMEOUT Hikari wait exhausted +DB_CONNECT_TIMEOUT physical connection/bootstrap +DB_UNAVAILABLE server/route unavailable +``` + +각 결과는 서로 다른 운영 대응과 metric을 가진다. acquire 실패에는 DB statement가 실행되지 +않았음을 보존한다. + +## 17. Deadline과 timeout 계층 + +### 17.1 기본 부등식 + +한 operation의 목표 계층: + +```text +0 < lock_timeout + < statement_timeout + <= Spring transaction timeout + < remaining CallBudget +``` + +connection acquisition과 admission도 `remaining CallBudget` 안에 있어야 하며 response +serialization/cancellation margin을 남긴다. + +Hikari가 operation별 wait timeout을 받지 않으므로 “남은 시간과 동적으로 교차한다”라고 +과장하지 않는다. baseline은 두 번의 fail-fast gate를 사용한다. + +```text +before admission: + remaining >= admissionBudget + + connectionTimeout + + beginBudget + + minimumActionWindow + + completionMargin + +after admission, before getConnection: + remaining >= connectionTimeout + + beginBudget + + minimumActionWindow + + completionMargin +``` + +두 번째 gate를 통과하지 못하면 pool을 호출하지 않는다. + +정확히 모든 operation에 lock timeout이 필요한 것은 아니다. lock을 사용하지 않는 query는 +policy의 작은 default를 유지하거나 명시적으로 적용하지 않을 수 있다. 그러나 무한 대기는 +허용하지 않는다. + +### 17.2 effective timeout 계산 + +```text +remaining = callBudget.remaining(now) +preAcquireRequired = + pool.connectionTimeout + + policy.beginBudget + + policy.minimumActionWindow + + policy.completionMargin + +if remaining < preAcquireRequired: + reject before pool acquisition + +safeTxWindow = + remaining + - pool.connectionTimeout + - policy.beginBudget + - policy.completionMargin + +springTimeoutSeconds = + floor_seconds(min(policy.transactionTimeout, safeTxWindow)) + +if springTimeoutSeconds < 1: + reject before delegate.getTransaction() + +definition.timeout = springTimeoutSeconds +status = delegate.getTransaction(definition) + +remainingAfterBegin = callBudget.remaining(now) +springWindowRemaining = + springTimeoutSeconds - elapsedSinceGetTransactionStarted +statementWindow = + min(remainingAfterBegin - completionMargin, springWindowRemaining) + +statementTimeout = min(policy.statementTimeout, statementWindow - txMargin) +lockTimeout = min(policy.lockTimeout, statementTimeout - lockMargin) +``` + +Spring timeout은 per-call `TransactionDefinition`을 만든 뒤 +`delegate.getTransaction(definition)`을 호출하기 전에 결정한다. begin 뒤 남은 window가 +statement/lock timeout에 부족하면 첫 business statement 전에 rollback한다. 어느 변환에서도 +0이 framework default/unlimited 의미가 되지 않게 ceil/floor 규칙을 test한다. + +Spring transaction timeout은 초 단위 정수이므로 usable budget을 넘지 않는 양의 floor만 +사용한다. floor가 1초 미만이면 transaction을 시작하지 않는다. PostgreSQL millisecond timeout이 +있다고 Spring timeout을 0/default로 두지 않는다. `999ms`, `1000ms`, `1001ms`와 margin +경계 test가 안전한 reject/1초 선택을 고정한다. + +### 17.3 PostgreSQL local timeout + +PostgreSQL package가 transaction 시작 직후 다음을 transaction-local로 적용한다. + +```sql +select set_config('statement_timeout', :statement_timeout_text, true); +select set_config('lock_timeout', :lock_timeout_text, true); +select set_config('idle_in_transaction_session_timeout', :idle_guard_timeout_text, true); +``` + +또는 동등한 parameterized `SET LOCAL` protocol을 사용한다. + +규칙: + +- session-level state를 pool에 누출하지 않는다. +- route/tenant/timeout context는 첫 business statement 전에 설정한다. +- local 설정 실패 시 business query를 진행하지 않는다. +- nested `REQUIRED`는 outer transaction timeout/route를 늘리거나 바꾸지 못한다. +- participating inner deadline이 더 짧으면 physical transaction context의 + statement/lock/idle GUC를 현재 값과 inner effective 값의 minimum으로 한 번 더 낮춘다. + 이 축소는 inner 종료/예외 뒤 복원하지 않고 physical transaction 종료까지 sticky하다. + outer의 이후 statement도 축소된 GUC와 absolute `CallBudget` pre-gate를 사용한다. +- participant는 Spring transaction timeout을 다시 설정하지 않고, local GUC를 늘리거나 + outer absolute deadline을 연장할 수 없다. +- PostgreSQL 16 baseline에 없는 기능을 사용 가능하다고 가정하지 않는다. +- `set_config`의 value는 PostgreSQL이 요구하는 text로 명시적으로 변환하며, bind parameter를 + 지원하지 않는 raw `SET LOCAL ... ?` 문자열을 만들지 않는다. + +sticky minimum을 선택한 이유는 같은 physical transaction에서 `set_config(..., true)`가 +transaction 종료까지 유지되기 때문이다. 저장/복원으로 outer budget을 다시 늘리지 않는다. +`outer-before → tighter inner → outer-after`, caught inner exception, inner statement timeout과 +absolute deadline 경계를 real PostgreSQL에서 검증한다. inner timeout으로 transaction이 abort +상태가 되면 catch 후 계속하지 않고 rollback-only로 종료한다. + +PostgreSQL 16에서는 server-side `statement_timeout`, `lock_timeout`, +`idle_in_transaction_session_timeout`과 Spring transaction timeout을 조합한다. 이것을 +kernel/network까지 포괄하는 hard cancellation이라고 표현하지 않는다. + +`idle_in_transaction_session_timeout`은 statement/lock total-deadline 부등식의 한 항이 +아니라 “business statement 사이에 허용할 최대 idle gap”을 막는 별도 operational guard다. +remote I/O나 user think time을 transaction 안에서 기다리지 않는다는 invariant에서 policy별로 +산정하고, statement/lock timeout과 같은 `:milliseconds` 변수를 재사용하지 않는다. + +### 17.4 JDBC timeout + +- query timeout은 statement execution guard다. +- socket timeout은 network read guard지만 commit uncertainty를 만들 수 있다. +- connect/login timeout은 physical connection bootstrap guard다. +- Hikari connection timeout은 pool wait guard다. + +하나의 `DB_TIMEOUT`으로 합치지 않는다. driver property의 단위와 interaction을 typed settings +validation과 real fault test로 검증한다. + +### 17.5 cancellation + +caller cancellation이 transaction thread interruption과 정확히 같은 의미라고 가정하지 않는다. + +- cancel signal을 받으면 가능한 경우 JDBC statement cancel을 요청한다. +- rollback/connection cleanup 완료 전 성공 또는 재실행 가능 결과를 반환하지 않는다. +- cancel이 commit 단계와 겹치면 outcome은 indeterminate일 수 있다. +- interrupted flag를 보존한다. +- cancellation metric은 query timeout과 분리한다. + +## 18. Write, batch와 persistence context + +### 18.1 단일 aggregate command + +- aggregate를 primary transaction에서 읽고 변경한다. +- expected version이 있으면 version predicate를 검증한다. +- domain transition 뒤 mapper가 persistence state를 반영한다. +- outbox가 필요하면 같은 transaction에서 append한다. +- error mapping이 필요한 constraint는 commit 전 explicit flush할 수 있다. +- remote publication은 commit 뒤 별도 relay가 한다. + +### 18.2 insert batching + +batching은 entity ID strategy, JDBC driver rewrite, Hibernate ordering과 함께 검증한다. + +초기 후보: + +```text +hibernate.jdbc.batch_size +hibernate.order_inserts +hibernate.order_updates +``` + +잠긴 Hibernate `7.1.8`의 실제 `BatchSettings`에 존재하는 설정만 허용한다. 다른 버전의 +문서에서 본 property를 추정해 추가하지 않으며, startup property allowlist test로 exact +version을 검증한다. 설정 존재만으로 batching이 작동한다고 주장하지 않는다. real +PostgreSQL에서 versioned entity를 포함한 statement/round trip 또는 datasource proxy evidence를 +확인한다. + +UUID application-generated ID는 insert batching과 잘 맞지만 index locality와 page split 비용을 +부하 test로 본다. sequence를 도입하면 allocation size와 rollback gap을 정상 동작으로 +문서화한다. + +### 18.3 bounded batch + +maintenance/backfill/import: + +```text +claim/read bounded keys +-> transaction per bounded batch +-> write +-> flush +-> clear +-> persist checkpoint +-> next batch +``` + +규칙: + +- row count와 byte/time budget을 모두 둔다. +- 전체 dataset을 persistence context에 보관하지 않는다. +- transaction마다 remote I/O를 하지 않는다. +- failure 후 같은 checkpoint에서 안전하게 재시작한다. +- batch size는 configuration upper bound를 넘지 않는다. +- partial progress와 retry semantics를 runbook에 남긴다. + +### 18.4 bulk DML + +JPQL/native bulk update/delete는 다음 조건에서만 허용한다. + +- aggregate invariant를 우회해도 되는 infrastructure state; +- explicit version/audit predicate와 mutation; +- persistence context clear; +- affected-row count assertion; +- concurrent worker test; +- named operation/query ID. + +일반 domain aggregate의 상태 전환을 bulk DML에 숨기지 않는다. + +### 18.5 upsert + +PostgreSQL `INSERT ... ON CONFLICT`는 adapter 내부의 명시적 arbitration operation에만 사용한다. + +- conflict target을 named schema constraint와 맞춘다. +- insert와 update의 application 의미를 typed outcome으로 분리한다. +- update predicate에 owner token/version을 포함한다. +- arbitrary entity save를 upsert로 바꾸지 않는다. +- returned row와 affected-row semantics를 real PostgreSQL에서 검증한다. + +## 19. Query, fetch와 N+1 + +### 19.1 query catalog + +운영 가치가 있는 query에는 stable low-cardinality ID를 부여한다. + +```text +work_item.summary.by_owner.v1 +outbox.delivery.claim.v2 +idempotency.resolve.v2 +``` + +query catalog는 최소 다음을 기록한다. + +| Field | 의미 | +| --- | --- | +| query catalog ID | metric/trace/plan의 stable low-cardinality key | +| owner port/method | application semantic owner | +| consistency | strong/eventual/bounded | +| max rows/bytes | resource bound | +| sort/order | deterministic order | +| expected index | structural plan expectation | +| statement budget | N+1 포함 최대 count | +| timeout policy | named policy | +| sensitive fields | log/trace redaction | + +raw SQL text나 parameter를 metric tag로 쓰지 않는다. +이 ID는 요청별 query/operation instance나 reconciliation `OperationId`가 아니다. + +### 19.2 projection 우선 + +list/search/read-model은 필요한 column만 application projection으로 읽는다. + +- JPQL constructor/interface projection은 단순한 provider-neutral query에 사용한다. +- native/JDBC projection은 PostgreSQL-specific operator, CTE, window, keyset, claim이 필요한 + 경우 사용한다. +- entity 전체를 읽은 뒤 web DTO로 대량 변환하는 것을 기본으로 하지 않는다. +- projection constructor와 alias drift를 compile/integration test한다. + +### 19.3 fetch plan + +aggregate load에는 use case별 explicit fetch plan을 둔다. + +- entity graph; +- fetch join; +- batch fetch; +- secondary bounded query. + +하나의 global eager mapping으로 해결하지 않는다. collection fetch join과 paging의 조합은 +row multiplication/메모리 paging 위험이 있으므로 사용하지 않거나 two-step key query로 +분리한다. + +### 19.4 N+1 budget + +대표 use case test는 result correctness와 함께 statement count upper bound를 검증한다. + +예: + +```text +summary page 50 rows: + expected <= 2 statements +aggregate detail: + expected <= 3 statements +``` + +정확한 count는 query design에 따라 다르지만 row 수에 비례해 증가하면 실패해야 한다. +Hibernate statistics 또는 datasource instrumentation은 test profile에서만 상세 정보를 +수집하고 production에서는 bounded recorder를 사용한다. + +### 19.5 count query + +total count는 비용이 있으므로 API가 정말 요구할 때만 실행한다. + +- `Slice`/cursor는 `limit + 1`로 `hasNext`를 계산한다. +- Page total이 필요하면 목적별 count query와 index를 설계한다. +- collection join이 있는 auto-generated count query를 신뢰하기 전에 plan/result를 검증한다. +- approximate count는 정확한 total과 다른 typed contract로 분리한다. + +### 19.6 dynamic query + +허용 filter/sort catalog를 application enum/value로 고정한다. + +- empty predicate 의미를 정의한다. +- optional filter 조합 수와 plan을 검증한다. +- string concatenated SQL을 만들지 않는다. +- native identifier가 필요하면 allowlist에서만 선택한다. +- generic `Specification`을 application port로 노출하지 않는다. + +### 19.7 query timeout과 slow query + +query ID별 timeout policy를 사용한다. slow query log는 다음을 지킨다. + +- SQL parameter/PII 미기록; +- normalized query ID; +- elapsed time, row count, route, outcome; +- sampling/rate limit; +- trace correlation; +- stack trace는 반복 rate limit. + +PostgreSQL `pg_stat_statements`를 운영 query aggregate 근거로 사용할 수 있지만 extension 설치와 +data retention은 deployment responsibility다. application metric과 database view를 query +ID/normalized shape로 연결한다. + +## 20. Pagination과 large read + +### 20.1 inbound bound + +inbound validation과 application value object가 다음을 강제한다. + +- positive limit; +- per-query maximum; +- allowlisted sort; +- stable tie-breaker; +- cursor maximum length; +- malformed/expired/version-unknown cursor rejection. + +adapter가 음수/과대 page를 임의 default로 바꿔 성공시키지 않는다. + +### 20.2 offset pagination + +offset은 다음에만 사용한다. + +- shallow, bounded admin/list page; +- total page UX가 실제 요구; +- maximum offset이 명시됨; +- stable order와 index가 있음. + +deep offset export/scan에는 사용하지 않는다. + +### 20.3 keyset cursor + +keyset 기본 shape: + +```text +ORDER BY sort_key DESC, id DESC +WHERE (sort_key, id) < (:last_sort_key, :last_id) +LIMIT :limit_plus_one +``` + +cursor는 versioned opaque envelope로 만든다. + +```text +version +queryShapeId +sortKey +tieBreaker +filterFingerprint +issuedAt/optional expiry +integrity MAC when client-visible tampering matters +``` + +cursor에 PII를 plaintext로 넣지 않는다. filter/sort가 바뀐 cursor를 재사용하면 +`CURSOR_MISMATCH`로 거절한다. + +### 20.4 snapshot 의미 + +여러 page request 사이에는 일반적으로 새 write가 들어올 수 있다. keyset은 duplicate/skip을 +줄이지만 전체 snapshot을 보장하지 않는다. + +정확한 snapshot이 필요하면: + +- bounded single transaction; +- materialized export job/snapshot table; +- version/as-of predicate; +- 별도 analytical store + +중 하나를 선택한다. web request에 long-running open transaction을 유지하는 것을 기본으로 +하지 않는다. + +### 20.5 streaming + +JPA stream은 transaction과 connection을 stream close까지 보유한다. 따라서: + +- application port에 raw `Stream`를 노출하지 않는다. +- try-with-resources close ownership을 adapter가 보장한다. +- row/time/byte limit를 둔다. +- HTTP client 속도에 DB connection lifetime을 직접 묶지 않는다. +- large export는 checkpointed job이 DB batch를 읽고 file/object storage에 publish한다. + +## 21. Primary/replica와 read consistency + +### 21.1 consistency vocabulary + +```java +public sealed interface ReadConsistency { + record Strong() implements ReadConsistency {} + record ReadYourWrites(SessionWriteMarker marker) implements ReadConsistency {} + record BoundedStaleness(Duration maximumLag) implements ReadConsistency {} + record Eventual() implements ReadConsistency {} +} +``` + +exact API shape는 구현 계획에서 조정할 수 있지만 의미는 다음과 같이 고정한다. + +| Consistency | v1 route | 보장 | +| --- | --- | --- | +| `STRONG` | primary | 현재 writable authority에서 각 statement 시작 시점의 committed snapshot | +| `READ_YOUR_WRITES` | primary | 같은 authority/timeline 안에서 marker까지 포함한 read | +| `BOUNDED_STALENESS(maxLag)` | endpoint-bound lag-qualified replica, 실패 시 명시 policy | 실제 query backend에 결속된 보수적 관측 lag가 bound 이내 | +| `EVENTUAL` | role-qualified replica | 최신성 bound 없음 | + +replica read가 linearizable하다고 주장하지 않는다. primary read도 여러 statement 사이 repeatable +snapshot을 뜻하지 않는다. + +### 21.2 route 결정 시점 + +route는 transaction/connection acquisition 전에 결정한다. + +```text +resolve policy + consistency +-> bind route context +-> begin transaction +-> datasource router selects pool +-> connection acquired +``` + +transaction이 시작된 뒤 route를 바꾸지 않는다. nested `REQUIRED` query는 outer primary +transaction 안에서 replica로 downgrade되지 않는다. + +### 21.3 nested rule + +- outer write transaction 안의 모든 read는 primary다. +- outer strong read 안의 nested eventual request도 primary다. +- outer replica transaction 안에서 write를 시도하면 fail-fast/DB read-only failure다. +- `REQUIRES_NEW`로 route를 바꾸는 것은 allowlisted policy에서만 가능하고 pool capacity를 + 포함해 검증한다. +- async thread에 route context를 암묵적으로 전파하지 않는다. + +Spring transaction과 JDBC는 thread-bound이므로 transaction callback 안에서 async/fork를 +금지한다. virtual thread 하나가 transaction lifetime 동안 같은 logical execution을 유지한다. + +### 21.4 lag qualification + +bounded-staleness route는 provider가 다음을 제공할 때만 활성화한다. + +- monotonic 또는 conservative lag observation; +- observation timestamp와 TTL; +- replica replay state; +- stale/unknown 상태; +- failover role detection. + +qualification 결과는 일반 boolean이 아니라 다음 internal value와 동등해야 한다. + +```text +QualifiedReplicaRoute( + endpointId, + poolGeneration, + roleEpoch, + observedAt, + observedLagUpperBound, + observationErrorMargin, + expiresAt, + evidence) +``` + +replica pool generation 하나는 qualification 대상인 한 physical/provider logical endpoint에 +고정한다. borrowed connection의 backend identity와 read-only role이 qualification의 +endpoint/role epoch와 같은지 첫 business query 전에 확인한다. reconnect, DNS target change, +promotion/failover, pool generation 교체가 발생하면 기존 qualification을 즉시 폐기한다. +여러 standby를 숨긴 load-balanced endpoint가 이 결속을 제공하지 못하면 그 pool은 +bounded-staleness에 사용할 수 없다. + +query 직전 eligibility는 monotonic time으로 다음을 계산한다. + +```text +effectiveLagUpperBound = + observedLagUpperBound + + (monotonicNow - observedAt) + + observationErrorMargin + +eligible iff + monotonicNow <= expiresAt + and effectiveLagUpperBound <= requestedMaximumLag + +expiresAt <= + observedAt + + requestedMaximumLag + - observedLagUpperBound + - observationErrorMargin +``` + +음수/0 window는 즉시 ineligible이다. provider가 준 TTL을 그대로 신뢰하지 않고 위 식과 +provider TTL 중 더 이른 시각을 사용한다. + +v1 `BOUNDED_STALENESS` query는 statement budget 1인 fully materialized projection으로 제한한다. +N+1, lazy load, data+count 두 statement, streaming을 허용하지 않는다. 따라서 qualification은 +borrowed backend 확인 뒤 첫 business statement 직전에 한 번 검증하고 result를 모두 +materialize한 뒤 connection을 반환한다. multi-statement bounded read가 필요하면 같은 backend의 +각 statement 전 재qualification 또는 pinned snapshot semantics를 별도 card로 설계한다. + +provider가 replay timestamp/LSN을 요청 시각과 보수적으로 비교할 time-lag oracle을 제공하지 +못하면 v1은 `EVENTUAL`만 활성화하고 `BOUNDED_STALENESS` descriptor를 등록하지 않는다. lag +unknown은 bound satisfied가 아니다. fallback policy: + +```text +FAIL_CLOSED +FALLBACK_PRIMARY +RETURN_STALE_UNAVAILABLE +``` + +를 query policy별로 고정한다. fallback primary는 metric과 trace event를 남긴다. +qualification 실패의 primary fallback은 transaction 시작 전에만 가능하다. mid-query +disconnect 또는 일부 결과 노출 뒤에는 §16.2의 replay 제한을 적용한다. + +### 21.5 read-your-writes + +v1은 같은 request/session의 RYW를 primary route와 `SessionWriteMarker(authorityEpoch, +operationId/sourceRevision)`로 제공한다. 단순히 primary URL을 선택했다는 이유만으로 failover +뒤 RYW를 주장하지 않는다. asynchronous replication의 RPO window에서 acknowledged write가 새 +primary에 없을 수 있으므로 authority/timeline이 바뀌면 marker를 operation ledger로 reconcile해 +확인하거나 `CONSISTENCY_UNAVAILABLE`을 반환한다. + +read consistency와 durability는 별도 descriptor다. RYW/STRONG label이 provider의 synchronous +commit, zero-RPO 또는 failover durability를 암시하지 않는다. WAL LSN token을 client에 +전달하고 replica replay를 기다리는 최적화는 다음을 별도 검증한 뒤에만 도입한다. + +- token integrity와 topology binding; +- failover timeline; +- wait timeout; +- privacy; +- replica replay API; +- primary fallback. + +### 21.6 health와 failover + +replica가 optional인 strong-only deployment에서는 replica 장애가 application readiness를 +내리지 않는다. replica-required query profile이면 readiness descriptor에 degraded/unavailable을 +반영한다. + +primary endpoint가 read-only standby로 바뀌면 write readiness가 실패해야 한다. driver +multi-host failover 뒤에도 role probe를 다시 수행한다. + +multi-replica qualification, 다른 backend가 선택되는 load-balanced endpoint, reconnect, +promotion 전후 marker, result를 일부 소비한 뒤 disconnect를 real topology/fault test에 +포함한다. + +## 22. Same-store reliability capability + +### 22.1 공통 원칙 + +idempotency, outbox, inbox는 단순한 table helper가 아니다. 각자 semantic port, state machine, +owner token, retention, reconciliation, metric, runbook을 가진 capability card다. + +공통 invariant: + +- scope/key는 canonical versioned encoding; +- request/message/event intent hash가 있다. +- claim은 owner token과 finite lease를 반환한다. +- renew/complete/release는 current owner+attempt+operation ID+state revision+status를 CAS한다. +- affected row count를 assert한다. +- database clock으로 lease를 비교한다. +- payload size/schema/version upper bound가 있다. +- reaper가 live owner를 삭제하지 않는다. +- idempotency/inbox와 polling delivery의 terminal delete는 business occurred time이 아니라 + 각 state machine의 terminal time을 기준으로 한다. +- unknown outcome은 같은 key로 reconcile한다. + +lease 비교는 lock wait 전에 고정되는 `transaction_timestamp()`/`statement_timestamp()`가 아니라 +row lock을 얻은 뒤 mutation CTE에서 한 번 평가한 `clock_timestamp()`의 `db_now`를 +predicate와 새 `lease_until`에 함께 사용한다. audit/event `created_at`처럼 한 transaction의 +일관된 기록 시각은 `transaction_timestamp()`를 사용할 수 있으나 lease authority와 섞지 +않는다. CDC outbox에는 terminal row/time이 없으므로 이 terminal-time reaper invariant를 +적용하지 않는다. CDC의 `retention_bucket`은 trusted database insertion time만으로 정하고 +`occurred_at`은 business intent/audit 값일 뿐 retention authority가 아니다. CDC cleanup은 +§22.3의 checkpoint/high-watermark, replay retention, incident/legal hold, snapshot 조건을 +모두 충족해야 한다. + +### 22.2 owner-safe JPA idempotency V2 + +canonical scope: + +```text +tenant? +principal/client? +operation +idempotencyKeyDigest +``` + +raw client key를 table/index/log/metric에 저장하지 않는다. request fingerprint에는 method/path +같은 transport 문자열이 아니라 canonical application intent를 포함한다. + +저엔트로피 key에 단순 hash만 적용하면 database 유출 뒤 사전 대입이 가능하다. scope에는 +versioned HMAC digest를 기본으로 하고 `key_digest_version`을 저장한다. 새 write는 active key +version, read/replay는 제한된 prior-version window만 허용하며 rotation 완료 뒤 old version을 +contract한다. key 자체가 검증된 충분한 entropy를 가진다고 주장하려면 입력 계약과 test +evidence가 필요하다. + +권장 schema: + +```text +idempotency_record + scope_hash primary/unique key component + key_digest_version + operation_code + request_fingerprint + state CLAIMED | EXECUTING | COMPLETED | FAILED_RETRYABLE | ABANDONED + state_revision + owner_token + lease_until + attempt + claim_operation_id + last_transition_operation_id + last_transition_kind + last_transition_result_digest + reconciliation_evidence_digest + response_schema + response_inline + response_digest + replay_until + created_at + updated_at + completed_at + expires_at +``` + +JPA와 Redis provider는 `application-core`의 동일한 Idempotency V2 state/result contract를 +구현한다. provider가 다르다고 claim/execution vocabulary를 축약하지 않는다. claim algorithm은 +single statement UPSERT 또는 lock/CAS로 최소 다음 결과를 구분한다. + +```text +ACQUIRED(ownerToken, attempt, leaseUntil) +REPLAYED_ACQUIRE(ownerToken, attempt, leaseUntil) +TAKEN_OVER_CLAIMED(ownerToken, attempt, leaseUntil) +COMPLETED_REPLAY(response, replayUntil) +IN_PROGRESS(retryAfter) +RECOVERY_REQUIRED(currentAttempt) +FINGERPRINT_MISMATCH +OWNER_OPERATION_CONFLICT +INDETERMINATE(operationId) +UNAVAILABLE +``` + +state machine: + +```text +CLAIMED -> EXECUTING -> COMPLETED + | |-----> FAILED_RETRYABLE + | \-----> ABANDONED + \-> release before execution + +expired CLAIMED -> takeover CLAIMED with attempt+1 +expired EXECUTING -> ABANDONED/RECOVERY_REQUIRED, no blind takeover +ABANDONED -> verified committed reconciliation + -> verified no-effect reopen +``` + +`markExecutionStarted`, `renew`, `complete`, `markFailed`, `releaseBeforeExecution`, +`inspect`, `reconcileCommitted`, `reconcileNoEffectAndReopen`의 typed outcomes와 replay rule도 +Redis §24의 application contract를 그대로 사용한다. 모든 mutation은 다음 tuple을 +검증하고 `state_revision`을 증가시킨다. + +```sql +update idempotency_record + set state = 'COMPLETED', + state_revision = state_revision + 1, + last_transition_operation_id = :transition_operation_id, + ... + where scope_hash = :scope + and state = 'EXECUTING' + and owner_token = :owner + and attempt = :attempt + and state_revision = :expected_revision + and claim_operation_id = :claim_operation_id +``` + +exact column 사용은 transition별로 다듬되 `scope + current state + owner + attempt + +operation ID + state revision`보다 약한 CAS는 허용하지 않는다. 같은 transition operation ID와 +result digest의 duplicate는 prior result를 replay하고 다른 digest는 conflict다. affected +row가 0이면 성공으로 간주하지 않는다. read-back/inspect로 owner mismatch, state revision +conflict, expired takeover, already completed, effect unknown을 분류한다. + +#### SAME_STORE_TRANSACTIONAL + +inline response가 bounded한 command의 R2 기본 choreography는 business write와 idempotency +transition을 같은 primary PostgreSQL transaction에 넣는다. + +```text +transaction { + claim row INSERT/SELECT FOR UPDATE + verify fingerprint/state/owner/attempt/revision + CLAIMED -> EXECUTING + business write + outbox append + idempotency complete +} +``` + +pre-claim을 admission 목적으로 별도 transaction에서 commit한 profile도 business mutation의 +첫 단계에서 claim row를 bounded `FOR UPDATE`로 잠그고 owner/status/lease/attempt/revision을 +다시 검증한 뒤 lease를 갱신한다. 이 row lock은 business write와 completion commit까지 +유지한다. A가 lock을 가진 동안 lease 시각이 지나도 B의 takeover update는 진행할 수 없으며, +A가 rollback하면 business write와 completion이 함께 사라진다. B가 먼저 takeover했다면 A는 +business write 전에 owner CAS에서 실패한다. completion affected-row 0은 callback failure로 +전파해 전체 business transaction을 rollback한다. + +lock을 잡은 채 remote I/O, user callback 대기, unbounded work를 하지 않는다. row lock 없이 +pre-claim owner를 읽기만 한 뒤 business write를 시작하는 choreography는 금지한다. +`lease expires while A holds the business transaction and B attempts takeover` barrier test는 +B가 동시에 business mutation을 실행하지 못하고 business row가 정확히 한 번만 바뀌는지 +검증한다. + +`SAME_STORE_TRANSACTIONAL` R2는 bounded inline response만 광고한다. DB transaction 안에서 +object storage `put/get/complete`를 호출하지 않는다. 큰 response reference는 +`PENDING_RESPONSE -> staged upload -> finalize/reconcile`와 orphan cleanup을 가진 별도 +cross-store response card가 설계·검증되기 전에는 이 guarantee에서 제외한다. 현행 V1 +`response_ref` compatibility read가 필요해도 이를 V2 atomic guarantee로 표시하지 않으며 +object-store 미활성 상태의 reference는 silent miss가 아니라 profile incompatibility다. + +### 22.3 immutable outbox event와 delivery V2 + +권장 schema: + +```text +outbox_event_identity_v2 unpartitioned uniqueness guard + tenant? + event_id + aggregate_type + aggregate_id + aggregate_version + event_ordinal + retention_bucket + created_at + +outbox_publication_control_v2 one row for PRIMARY outbox scope + scope_id + active_epoch + active_authority LEGACY_POLLING | POLLING_V2 | CDC + state PREPARING | ACTIVE | DRAINING + revision + updated_at + +outbox_publication_cutover_v2 immutable authority sentinel/audit + scope_id + active_epoch + previous_epoch + transition_kind GENESIS_FRESH | GENESIS_LEGACY | CUTOVER + active_authority + legacy_row_count + legacy_pending_count + legacy_digest + schema_manifest_id + external_manifest_id? + activated_at + +outbox_event_log_v2 RANGE(retention_bucket) + retention_bucket + event_id + aggregate_type + aggregate_id + aggregate_version + event_ordinal + event_type + event_schema + logical_destination + partition_key? + publication_epoch + dispatch_authority LEGACY_SHADOW | POLLING_V2 | CDC + content_type + correlation_id? + causation_id? + occurred_at + payload + payload_digest + trace_parent? + tenant? + created_at + +outbox_delivery_v2 + tenant? + retention_bucket + event_id + destination + state PENDING | CLAIMED | PUBLISHED | RETRY_WAIT | DEAD + claim_owner + claim_token + claim_until + attempt + next_attempt_at + last_error_code + published_at + dead_at + version +``` + +constraints: + +- `outbox_publication_control_v2`의 baseline `scope_id=PRIMARY` primary key와 정확히 한 + active authority; +- `outbox_publication_cutover_v2(scope_id, active_epoch)` primary key와 cutover procedure의 + same-scope `previous_epoch + 1` monotonic check; +- unpartitioned identity guard의 `event_id` primary key; +- identity guard의 + `(aggregate_type, aggregate_id, aggregate_version, event_ordinal)` unique; +- identity guard의 `(event_id, retention_bucket)` unique; +- range-partitioned event의 `(retention_bucket, event_id)` primary key와 같은 두 column의 + identity guard foreign key; +- delivery의 `(retention_bucket, event_id, destination)` primary key와 event composite + foreign key; +- claim eligibility/order index; +- payload size/check constraint; +- state별 required field check. + +위 key 표기는 기본 `tenantMode=NONE` profile이다. tenant card를 선택하면 identity/event/ +delivery의 `tenant_id`는 non-null이고 aggregate uniqueness, identity-event FK와 +event-delivery PK/FK를 tenant-prefixed composite key로 바꿔 cross-tenant reference를 +database가 거절한다. opaque `event_id`의 global uniqueness는 그대로 유지하고 tenant +composite constraint를 추가한다. publication control/cutover sentinel은 deployment authority이므로 +tenant-owned row가 아니며 §25의 별도 role/manifest 경계를 따른다. + +PostgreSQL 16 declarative partition table의 `PRIMARY KEY`/`UNIQUE`에는 모든 partition key가 +포함돼야 한다. 따라서 partitioned `outbox_event_log_v2`에 `event_id` 단독 PK나 aggregate tuple +단독 UNIQUE를 선언하지 않는다. 전역 event/aggregate-tuple uniqueness는 같은 transaction에 +먼저 쓰는 compact unpartitioned `outbox_event_identity_v2`가 보장하고, heavy envelope는 +time-range partition에서 보존/제거한다. identity guard는 R2 baseline에서 system lifetime +동안 보존하고 용량/backup을 별도로 산정한다. 이를 purge해 uniqueness를 bounded horizon으로 +낮추는 profile은 별도 card/evidence 없이는 활성화하지 않는다. guard에는 payload, +correlation/trace나 직접 PII를 두지 않고 opaque aggregate/event identity와 ordering tuple만 +둔다. 그 identity 자체가 개인정보가 될 수 있는 fork는 privacy owner, erasure/tombstone +정책과 guarantee downgrade를 별도로 설계한다. + +business transaction: + +```text +assert active primary read-write transaction +assert transaction manager/resource identity +select PRIMARY publication control FOR SHARE +derive allowed row authority from active control +assert publication epoch and authority mapping +aggregate update ++ immutable outbox_event_identity_v2 insert ++ immutable partitioned outbox_event_log_v2 insert with the same event/bucket ++ active POLLING_V2 only: initial outbox_delivery_v2 insert +COMMIT +``` + +`OUTBOX_APPEND`는 새 transaction을 여는 policy가 아니라 caller transaction에 반드시 참여하는 +operation이다. adapter는 Spring Data `save()`를 호출하기 전에 active primary read-write +transaction과 같은 `EntityManager`/datasource resource identity를 검증하고 없거나 다른 +transaction manager이면 fail-fast한다. repository의 암묵적 transaction으로 event row만 +commit되는 경로를 허용하지 않는다. architecture/integration test는 outer transaction 부재, +read-only transaction, 다른 transaction manager를 각각 거절하는지 검증한다. + +publication control의 `FOR SHARE`는 event insert와 business write가 commit/rollback될 때까지 +유지한다. 따라서 cutover의 `FOR UPDATE`와 충돌해 cutover 전 시작된 append가 commit 또는 +rollback되기 전에 authority가 바뀌지 않는다. V2 insert trigger/check는 row의 +`publication_epoch`이 잠근 control epoch와 같은지, authority가 +`LEGACY_POLLING -> LEGACY_SHADOW`, `POLLING_V2 -> POLLING_V2`, `CDC -> CDC` mapping인지 +검증한다. bridge의 `LEGACY_POLLING` authority에서는 full-intent V2 copy를 +`LEGACY_SHADOW`로만 허용하고 delivery insert를 거절한다. + +fresh install은 선택된 target authority로 epoch 1 control과 +`GENESIS_FRESH(previous_epoch=0, legacy counts=0, empty-set digest, exact schema/external +manifest IDs)` sentinel을 같은 migration transaction에서 만든다. 여기서 legacy adoption은 +`LEGACY_ADOPTED` origin을 처리하는 outbox-storage stream의 `V1__initialize_or_adopt`를 +뜻한다. 이 path는 `LEGACY_POLLING` epoch 1 control, 현재 V1 count/digest를 기록한 +`GENESIS_LEGACY` sentinel과 legacy mutation trigger를 같은 transaction에서 만든다. 어느 +genesis sentinel도 없거나 control/manifest와 다르면 writer/dispatcher/startup은 +fail-closed한다. + +ordering authority의 기본은 optimistic aggregate version과 transaction-local event ordinal의 +tuple이다. 한 domain transition에서 발생한 event는 application이 deterministic ordinal을 +부여한다. `event_id`, aggregate version, ordinal, occurred-at, payload와 digest는 +whole-transaction retry loop에 들어가기 전에 replay context로 고정한다. update command는 +expected aggregate version을 pin하고 retry 중 더 새 version을 만나면 event를 새 version으로 +조용히 rebase하지 않고 optimistic conflict로 끝낸다. `MAX(sequence)+1`은 금지한다. aggregate +version을 제공하지 못하는 destination은 descriptor를 `UNORDERED`로 낮추거나, 별도 +per-aggregate atomic counter row 설계와 contention evidence를 가져야 한다. + +relay: + +```text +short claim transaction: + select eligible delivery FOR UPDATE SKIP LOCKED + set CLAIMED + owner/token/until +COMMIT + +outside transaction: + publish message with stable event ID + +short completion transaction: + update by event + destination + owner/token + CLAIMED + -> PUBLISHED or RETRY_WAIT/DEAD +COMMIT +``` + +broker publish와 DB completion 사이 응답 유실은 duplicate publish를 만들 수 있다. consumer +idempotency/inbox가 필요하며 exactly-once라고 표현하지 않는다. + +aggregate strict order가 요구되면 order tuple N이 terminal/explicitly skipped되기 전 N+1을 +claim하지 않는다. 여기서 순서는 `(aggregate_version, event_ordinal)` tuple이다. DEAD head를 +무시할지 block할지는 destination policy와 operator audit로 결정한다. + +polling delivery reaper는 `published_at`/`dead_at`을 기준으로 terminal retention을 계산하고 +delivery를 먼저 제거한 뒤 payload partition/row를 정리한다. identity guard는 이 reaper가 +삭제하지 않는다. + +상위 activation SSOT의 `outbox.dispatch-mode`에 따라 storage/worker shape를 고정한다. + +- `polling`: `jpa-outbox-storage-v2`와 `jpa-outbox-polling-delivery-v2`를 selected로 하고 + immutable identity/event와 delivery를 같은 business transaction에 쓰며 polling relay만 + 활성화한다. +- `cdc`: `jpa-outbox-storage-v2`, `jpa-outbox-cdc-retention-v1`과 external + `messaging-cdc-dispatch.v1`을 selected/R2로 요구한다. immutable identity/event만 쓰며 CDC + connector가 consume하고 application polling delivery scheduler/table stream을 만들지 않는다. +- `disabled`: event append가 필요한 use case composition을 fail-fast하고 scheduler/table + activity가 없다. + +polling과 CDC worker가 같은 event를 동시에 publish하지 않도록 mode는 상호 배타적이다. CDC +connector offset/delivery guarantee는 별도 messaging/CDC card의 증거이며, JPA event insert만으로 +broker delivery R2를 주장하지 않는다. polling relay와 CDC connector predicate는 각각 +`outbox_publication_control_v2`와 같은 active epoch/authority 및 그 immutable authority +sentinel만 수용하고 `LEGACY_SHADOW`, stale/future epoch를 거절한다. + +CDC profile에서 immutable `outbox_event_log_v2`는 trusted database insertion time 기준 range +partition을 사용하고 event envelope에 logical destination과 stable `partition_key`를 저장한다. +application이 고정한 `occurred_at`은 과거/미래 시각일 수 있으므로 partition routing이나 +retention 판단에 사용하지 않는다. +ordered destination은 non-null key가 필수이며 기본 key는 tenant가 활성화되면 tenant와 aggregate +identity, 아니면 aggregate identity에서 deterministic하게 만든다. connector는 이 값을 broker +key로 전달한다. destination 설정이 ordered인데 key mapping을 증명하지 못하면 startup/card +activation을 실패시킨다. + +partition router는 identity insert에서 trusted DB time으로 결정하고 반환한 bounded +`retention_bucket`을 event/delivery에 동일하게 저장한다. application-supplied bucket과 open +partition allowlist 밖 bucket을 거절한다. identity conflict를 만난 reconciliation은 저장된 +bucket/digest를 읽지 새 bucket에 다시 넣지 않는다. adjacent partition에 같은 `event_id` 또는 +같은 aggregate version/ordinal을 동시에 넣는 race가 identity guard에서 정확히 한 건만 +성공하는지 PostgreSQL 16 concurrency/migration test로 검증한다. + +CDC에는 polling delivery의 `published_at`가 없으므로 terminal-row reaper를 재사용하지 않는다. +closed partition 제거 조건은 모두 충족해야 한다. + +1. connector가 partition의 source high watermark보다 뒤의 checkpoint를 durable하게 commit했다. +2. connector/control-plane이 그 high watermark까지 event를 consume했다는 immutable evidence를 + 제공한다. +3. configured replay retention과 incident hold가 지났다. +4. snapshot/recovery/replay가 그 partition을 더 요구하지 않는다. + +JPA maintenance code가 connector offset을 추측하지 않는다. bootstrap이 provider-neutral +checkpoint evidence contract를 조합하거나 외부 운영 job이 동일 조건을 증명하며, evidence가 +없거나 stale하면 cleanup은 fail-closed한다. cleanup delete/partition detach가 CDC change +record나 broker tombstone으로 routing되지 않도록 connector predicate를 고정한다. detach/drop은 +audit manifest, row/time bound와 restore reference를 남긴다. + +CDC card evidence에는 connector outage 동안의 partition growth, checkpoint 정지, restart, +snapshot cutover, retention 직전/직후, cleanup record filtering과 polling↔CDC mode 전환 +rehearsal가 포함된다. 전환은 write freeze 또는 backlog drain, connector offset/high-watermark +검증, duplicate 방지와 rollback point를 runbook으로 고정한다. 이 cross-card evidence가 없으면 +JPA outbox storage는 R2여도 CDC delivery/retention R2를 주장하지 않는다. + +### 22.4 same-store inbox + +scope: + +```text +consumer_group +handler_name +tenant? +message_id +``` + +권장 state: + +```text +RECEIVED -> PROCESSING(owner, lease) -> COMPLETED + \------> RETRYABLE / DEAD +``` + +DB-writing handler: + +```text +transaction { + claim row INSERT/SELECT FOR UPDATE + verify owner/attempt/state revision + apply business write + append outgoing outbox + complete inbox +} +ack broker after commit +``` + +commit 응답 유실 시 broker redelivery가 같은 inbox key를 reconcile한다. broker ack가 먼저 +나가면 안 된다. handler의 remote side effect는 outbox/workflow로 옮긴다. + +inbox execution mode를 혼합하지 않는다. + +- `TRANSACTIONAL_CLAIM`: claim/business write/outbox/completion을 한 transaction에 두는 R2 + 기본 mode다. +- `LEASED_PRECLAIM`: broker admission을 위해 claim을 먼저 commit할 수 있지만 business + transaction 첫 단계에서 row를 `FOR UPDATE`하고 owner/attempt/revision을 재검증하며 commit까지 + lock을 유지한다. + +preclaim을 읽기만 하고 business mutation을 수행하거나, completion CAS 실패를 broker ack 뒤 +경고로만 처리하는 구현은 금지한다. lease 만료 중 takeover barrier test는 old/new handler가 +business row를 동시에 변경하지 못하고 affected row가 정확히 한 번인지 검증한다. + +### 22.5 reaper와 maintenance ownership + +- bounded batch; +- owner-safe eligibility predicate; +- primary route; +- finite transaction/lock timeout; +- per-state retention; +- delete affected rows metric; +- dry-run/count diagnostic; +- shutdown 시 새 claim 중단; +- multi-instance efficiency lock이 실패해도 DB predicate가 correctness를 보장. + +scheduler adapter가 직접 policy를 숨긴 `@Transactional` method를 실행하지 않고 application +maintenance command 또는 명시적 infrastructure transaction policy를 호출한다. + +### 22.6 schema evolution 연계 + +JPA idempotency V1에서 V2로 이동할 때 Redis 심화 문서 §24가 사용하는 +`application-core` owner-safe state/result contract를 exact reuse한다. + +```text +expand owner/lease/attempt/operation/state-revision columns nullable +-> bridge V1 read + V2 write +-> backfill/version existing rows +-> switch full state machine and tuple CAS +-> drain old IN_PROGRESS rows +-> enforce NOT NULL/check/unique +-> observe owner mismatch/unknown outcome +-> contract V1 code/column +``` + +outbox V1에서 V2는 event/delivery dual-read보다 명시적 bridge가 필요하다. §23.3의 legacy +adoption 뒤 target-only 새 event는 identity guard + partitioned storage에 쓰고, polling mode만 +delivery stream에 함께 쓴다. 다만 현재 V3 row에는 V2의 aggregate type/version/ordinal과 +destination contract가 없으므로 legacy row를 fabricated ordering 값으로 V2에 backfill하지 +않는다. old writer가 만든 V1-only row는 V1 relay로 drain한다. bridge release가 full V2 intent를 +가진 새 event를 같은 stable event ID로 V1과 V2에 dual-write할 때만 V2 copy를 +`LEGACY_SHADOW`로 남긴다. 이 copy에는 delivery row를 만들지 않고 CDC connector도 filter한다. +V1/V2 event-ID reconciliation manifest가 legacy-only, matched-shadow, mismatch를 구분하며 +mismatch면 cutover를 중단한다. CDC cutover는 external connector watermark/epoch가 준비되기 +전 V1 row나 legacy shadow를 production CDC로 route하지 않는다. + +현재 V3가 만든 일반 table `outbox_event`는 PostgreSQL 16에서 in-place declarative partitioned +table로 바꿀 수 없으므로 V2 physical object는 처음부터 별도 이름 +`outbox_publication_control_v2`, `outbox_publication_cutover_v2`, +`outbox_event_identity_v2`, `outbox_event_log_v2`, `outbox_delivery_v2`를 사용한다. +`LEGACY_ADOPTED` origin의 outbox-storage stream `V1__initialize_or_adopt`가 V1 table의 +INSERT와 status UPDATE/DELETE trigger를 추가한다. legacy +`db/migration/postgresql` stream의 additive adoption migration은 fingerprint/marker만 +소유하고 이 target object를 만들지 않는다. trigger는 `PRIMARY` control row를 `FOR SHARE`로 +읽고 authority가 `LEGACY_POLLING`일 때만 mutation을 허용한다. 이 lock은 old append +transaction이 끝날 때까지 유지되므로 cutover `FOR UPDATE`가 in-flight old append를 +추월하지 못한다. trigger rejection은 old writer의 business transaction 전체를 +rollback시키며 event 없이 aggregate만 commit하는 경로를 허용하지 않는다. + +```text +bridge window: + DB control = (PRIMARY, epoch=N, LEGACY_POLLING, ACTIVE) + old writer -> outbox_event V1 + bridge/new writer with full V2 intent -> V1 + V2 LEGACY_SHADOW with the same event ID + V1 relay only; V2 relay/CDC production route disabled + checkpointed reconciler -> classify legacy-only/matched-shadow/mismatch, digest/row-count compare + +cutover: + freeze new outbox append + drain V1 pending/in-flight and stop old writer/relay binary + verify migrated already-published V2 events are immutable LEGACY_SHADOW rows with no delivery + external migration authority begins one DB transaction + lock PRIMARY control row FOR UPDATE and recheck epoch=N/LEGACY_POLLING + recheck V1 pending/in-flight=0 and matched-shadow digest + REVOKE INSERT, UPDATE, DELETE ON outbox_event FROM the exact runtime role + update control to (epoch=N+1, target authority, ACTIVE) + insert immutable outbox_publication_cutover_v2 sentinel for N+1 + COMMIT; target writer/dispatcher require the same control+sentinel + start exactly one target dispatcher + resume with a V2-only writer; new rows carry the new epoch/target dispatch authority + +contract: + pre-cutover rollback window와 V1 usage 0 확인 + drop old outbox_event only in a forward contract migration + keep V2 physical names stable; optional read-only diagnostic compatibility view만 허용 +``` + +V1/V2 relay가 같은 production destination을 동시에 publish하지 않는다. write freeze를 생략하는 +online dual-authority migration은 별도 fenced epoch/ACL 설계와 evidence 없이는 허용하지 않는다. +publication epoch은 immutable event row를 update하는 값이 아니라 DB control row의 monotonic +fencing 값이다. application activation manifest는 원하는 mode와 DB epoch/authority/sentinel이 +일치하는지만 검증하며 authority가 아니다. paused/partitioned old pod가 cutover 후 복귀하면 +legacy ACL과 trigger 중 적어도 하나에서 fail-closed하고 그 business transaction도 rollback한다. +runtime role이 legacy table owner/superuser여서 revoke/trigger를 우회할 수 있는 배포는 security +card와 cutover gate를 통과하지 못한다. append가 재개된 뒤에는 V1이 V2-only event를 표현할 수 +없으므로 old-binary rollback을 허용하지 않고 forward recovery만 수행한다. rollback rehearsal은 +target append를 다시 시작하기 전 pre-cutover point에서 target backlog 0/격리와 legacy schema +compatibility를 확인한다. + +## 23. Flyway와 rolling migration + +### 23.1 schema authority + +- production physical schema writer는 Flyway다. +- Hibernate `ddl-auto`는 production에서 `validate` 또는 `none`만 허용한다. +- `update`, `create`, `create-drop`은 startup validator가 production profile에서 거절한다. +- migration이 external job이어도 application startup은 schema compatibility를 검증한다. +- applied versioned migration은 immutable이다. +- checksum mismatch를 repair로 숨기지 않는다. + +application artifact는 `acceptedSchemaEpoch[min,max]`와 활성 feature별 +`requiredSchemaRevision` manifest를 포함한다. Flyway가 관리하는 infrastructure marker에는 +current schema epoch와 feature revision을 둔다. readiness validator는 다음을 서로 다른 +결과로 검증한다. + +1. Flyway history와 applied checksum integrity; +2. `min <= current schema epoch <= max`; +3. 활성 capability의 required feature revision 충족; +4. critical table/column/constraint/index shape probe; +5. Hibernate mapping validation. + +Flyway checksum이나 `ddl-auto=validate` 하나만으로 N/N-1 compatibility를 주장하지 않는다. +지원 matrix의 각 application artifact/schema epoch 조합과 negative startup 결과를 CI evidence +manifest에 남긴다. + +### 23.2 migration execution mode + +| Mode | 용도 | 규칙 | +| --- | --- | --- | +| `STARTUP` | local/dev, 단일 instance test | explicit lock/wait budget, failure 시 app startup 실패 | +| `EXTERNAL_JOB` | production 권장 | dedicated credential/job, app는 migrate하지 않고 validate | + +production에서 여러 pod가 동시에 migration을 시도하는 방식을 기본으로 하지 않는다. +external job이 성공하기 전 new application traffic을 열지 않는다. + +모든 production migration은 script metadata/runbook에 finite `lock_timeout`, +`statement_timeout`, maximum wall budget, dedicated `application_name`, transactional 여부를 +기록한다. job은 적용 전에 blocking/long-running transaction과 예상 lock conflict를 +read-only preflight하고 허용 범위를 넘으면 traffic을 막는 DDL을 시작하지 않는다. + +- transactional migration은 transaction 시작 직후 `SET LOCAL`/`set_config(..., true)`로 + timeout을 적용한다. +- nontransactional migration은 dedicated migration connection/session에 timeout을 설정하고 + 성공/실패 뒤 session reset이 증명되지 않으면 connection을 폐기한다. +- external job의 wall budget은 process/orchestrator deadline으로도 제한한다. +- timeout/failure 뒤 과거 migration을 수정하지 않고 partial object를 진단한 후 forward + recovery migration/runbook을 사용한다. + +old application DML이 계속되는 동안 일반 `ALTER TABLE` lock contention, timeout, partial +nontransactional artifact, forward recovery를 production-size PostgreSQL test에서 검증한다. + +### 23.3 optional capability migration stream + +한 Flyway history에서 비활성 card의 낮은 version을 건너뛰고 나중에 `outOfOrder=false`로 +적용하는 구조를 쓰지 않는다. core와 schema-bearing optional card는 독립 location/history +stream을 가진다. + +| Stream | Location | History table | +| --- | --- | --- | +| legacy adoption, transition only | `db/migration/postgresql` | `flyway_schema_history` | +| core target | `db/migration/jpa/core` | `flyway_jpa_core_history` | +| `jpa-idempotency-owner-safe-v2` | `db/migration/jpa/idempotency` | `flyway_jpa_idempotency_history` | +| `jpa-outbox-storage-v2` | `db/migration/jpa/outbox-storage` | `flyway_jpa_outbox_storage_history` | +| `jpa-outbox-polling-delivery-v2` | `db/migration/jpa/outbox-polling` | `flyway_jpa_outbox_polling_history` | +| `jpa-inbox-same-store-v1` | `db/migration/jpa/inbox` | `flyway_jpa_inbox_history` | +| `jpa-tenant-discriminator-rls` | `db/migration/jpa/tenant` | `flyway_jpa_tenant_history` | +| `jpa-jdbc-efficiency-coordination` | `db/migration/jpa/coordination` | `flyway_jpa_coordination_history` | + +`jpa-primary-replica`, `jpa-outbox-cdc-retention-v1`처럼 자기 schema object가 없는 card는 빈 +Flyway stream/history table을 만들지 않는다. 각 stream의 version은 그 stream 안에서만 단조 +증가하고 `outOfOrder=false`, `baselineOnMigrate=false`를 유지한다. optional migration은 core +object의 소유권을 임의로 이전하지 않으며 card별 schema revision과 core epoch prerequisite를 +`capability_schema_registry` marker에 기록한다. location/history/revision metadata의 +machine-readable SSOT도 §31.3의 card registry다. + +production external job은 하나의 allowlisted global migration orchestration lock 아래에서 +core를 먼저, 활성 optional stream을 dependency 순서로 migrate/validate한다. stream별 Flyway +lock만으로 서로 다른 history table의 DDL 충돌을 막을 수 있다고 가정하지 않는다. application +readiness는 활성 card의 history/checksum/revision을 요구하고, 이미 설치된 비활성 stream의 +revision도 현재 binary가 이해할 수 있는 범위인지 core marker로 확인한다. + +#### 현재 단일 history 채택 절차 + +현재 `db/migration/postgresql`의 V1/V3/V4/V5와 `flyway_schema_history`를 새 table에 복사하거나 +적용된 script를 수정하지 않는다. 전환은 다음 release sequence로만 수행한다. + +1. bridge release가 legacy V1/V3/V4/V5 checksum과 idempotency/outbox/`INT_LOCK` 실제 + object fingerprint를 exact allowlist로 검증한다. sample artifact의 V2 같은 registered + contribution은 sample owner manifest로 별도 검증하고 target sample history로 채택하며 + production core로 흡수하지 않는다. +2. 같은 legacy stream의 새 additive adoption migration이 + `capability_schema_registry`와 installation origin `LEGACY_ADOPTED`를 만든다. 예상 checksum, + column/constraint/index가 하나라도 다르면 중단한다. 이 legacy stream migration은 + fingerprint/marker만 소유하고 target outbox control/sentinel/trigger를 만들지 않는다. +3. 이 bridge release는 legacy history/location을 계속 사용하고 old/new application DML + compatibility를 제공한다. 아직 새 stream migration을 실행하지 않는다. +4. 모든 old migrator binary를 retire하고 external migration authority를 한 job으로 만든 뒤, + allowlisted `adoptJpaMigrationStreams` command만 각 target history를 explicit baseline + version `0`으로 초기화한다. 이것은 `baselineOnMigrate=true`가 아니며 preflight fingerprint, + global lock, operator approval와 audit manifest 없이는 실행할 수 없다. +5. 각 target stream의 immutable `V1__initialize_or_adopt`는 marker가 `FRESH`면 새 object를 + 만들고, `LEGACY_ADOPTED`면 검증된 legacy object를 유지한 채 additive V2 table/column과 + dual-read/write bridge를 만든다. outbox storage stream은 기존 V3 `outbox_event`를 + 유지한 채 `outbox_publication_control_v2`, `outbox_publication_cutover_v2`, + `outbox_event_identity_v2`, `outbox_event_log_v2`와 legacy mutation guard를 새로 만들며, + 같은 migration transaction에서 origin별 epoch 1 genesis sentinel도 만든다. polling + stream을 선택했을 때만 `outbox_delivery_v2`를 만든다. 그 밖의 shape/origin이거나 + control/sentinel 한쪽만 생성되면 실패한다. +6. backfill과 dual-write observation 뒤 target read/write를 switch한다. legacy table/path 사용 + 0과 old binary 0을 확인한 뒤에만 contract migration을 수행한다. +7. legacy history와 V1/V3/V4/V5 resource는 declared rollback window 동안 read-only + compatibility evidence로 유지하며 repair/delete하지 않는다. + +완전히 빈 database는 origin `FRESH`로 초기화하고 같은 explicit version-0 stream initialization +후 target core/selected optional V1부터 실행한다. legacy V1/V3/V4/V5를 실행하지 않으므로 fresh +disabled card는 schema/history side effect가 없다. legacy history가 있는데 adoption marker가 +없거나, 두 history authority가 동시에 write 가능하거나, non-empty schema인데 origin이 없으면 +fail-closed한다. + +CI는 실제 현재 V1/V3/V4/V5 schema/history snapshot에서 bridge→explicit baseline→V1 +adopt→backfill/switch를 실행한다. legacy checksum 한 글자 변경, constraint/index drift, +중단된 baseline/adoption, N/N-1 binary overlap과 rollback window를 negative/rolling test로 +검증한다. + +lifecycle 의미: + +```text +never installed + disabled + -> optional Flyway instance/history/schema object 없음 + +disabled -> enabled + -> external job이 해당 stream 전체 checksum 검증/migrate + -> feature revision 충족 뒤 runtime bean/worker 활성 + +enabled -> disabled + -> worker/bean/새 migration 실행 중단 + -> 기존 schema/history는 파괴하지 않고 INSTALLED_INACTIVE + +installed inactive -> enabled + -> 기존 checksum/accepted revision 검증 + -> forward migration 뒤 활성 +``` + +disable은 destructive rollback이 아니다. fresh disabled profile의 “schema 없음”과 이전에 설치된 +card를 비활성화한 “inert schema 잔존”을 descriptor에서 구분한다. 각 schema-bearing card는 +fresh disabled, first enable, disable after use, re-enable, interrupted migration, old/new binary +rolling 조합을 real PostgreSQL에서 검증한다. + +### 23.4 expand-contract 순서 + +```text +1. EXPAND + additive nullable column/table/index/constraint support +2. BRIDGE + old/new application이 함께 읽고 쓸 수 있는 code +3. BACKFILL + checkpointed bounded data conversion +4. SWITCH + canonical read/write를 new representation으로 전환 +5. ENFORCE + NOT NULL, validation, uniqueness, FK/check +6. OBSERVE + old path 사용 0, drift/invalid row 0 +7. CONTRACT + old column/index/code 제거 +``` + +N/N-1 compatibility: + +- schema S+1은 application N과 N-1을 안전하게 실행한다. +- application N은 migration 전/후 허용된 schema window를 명시한다. +- enum/check/column rename/drop은 한 release에 끝내지 않는다. +- rollback은 old binary가 new writes를 이해할 때만 가능하다. + +### 23.5 large backfill + +large backfill을 하나의 Flyway transaction에 넣지 않는다. + +- additive schema migration만 먼저 적용; +- 별도 application/ops job; +- stable key cursor와 checkpoint; +- bounded batch/timeout; +- idempotent update predicate; +- throttle와 pause; +- progress/error metric; +- old/new read reconciliation; +- 완료 후 validation/enforcement migration. + +backfill code와 schema window를 release artifact에 함께 추적한다. + +### 23.6 index migration + +large table의 production index는 `CREATE INDEX CONCURRENTLY`를 검토한다. + +- PostgreSQL transaction block 안에서 실행할 수 없는 migration은 Flyway + `executeInTransaction=false`로 명시한다. +- 하나의 mixed migration에서 transactional/nontransactional statement를 섞지 않는다. +- 실패한 concurrent build의 invalid index를 detect/cleanup하는 runbook이 필요하다. +- 같은 table에서 concurrent index build 제한과 deploy concurrency를 반영한다. +- index 생성 후 query plan과 write amplification을 관측한다. + +### 23.7 constraint enforcement + +대형 table의 check/FK는 가능한 경우: + +```text +ADD CONSTRAINT ... NOT VALID +-> validate existing rows/background repair +-> VALIDATE CONSTRAINT +``` + +를 사용한다. exact lock level과 PostgreSQL version 동작은 migration review에서 확인한다. +NOT NULL 전환은 null row 0, writer bridge, lock/time evidence 뒤에 수행한다. + +### 23.8 destructive change + +column/table drop, type narrowing, irreversible rewrite: + +- explicit data-retention owner; +- backup/restore point; +- old binary 사용 0; +- dual-read/write 종료; +- query/index dependency 검사; +- production-size rehearsal; +- roll-forward plan; +- change window. + +schema rollback file을 자동 생성한다고 무손실 rollback을 주장하지 않는다. 기본 복구 전략은 +forward fix다. + +### 23.9 migration location + +production core와 §23.3에서 selected인 optional stream location을 external migration +composition에서 명시적으로 결합한다. application runtime validator는 같은 stream registry를 +읽되 external-job mode에서 migrate하지 않는다. customizer가 default location을 교체하는지 +추가하는지 test한다. legacy `db/migration/postgresql` location은 `LEGACY_ADOPTED` transition +job/rollback window에서만 읽고 fresh target installation에는 결합하지 않는다. sample migration은 별도 sample-owned stream/history를 사용하며 production +artifact에 들어가지 않는다. sample artifact에는 필요한 production core/selected stream이 +빠지지 않게 manifest를 검증한다. + +### 23.10 PostgreSQL version + +현재 local baseline은 PostgreSQL 16이다. production qualification은: + +- 정확한 supported minor; +- container image digest 또는 managed engine version; +- pgjdbc/Flyway/Hibernate compatibility; +- extension 목록; +- upgrade/failover rehearsal + +를 기록한다. floating `postgres:16-alpine`은 local convenience일 뿐 immutable production +evidence가 아니다. + +## 24. Schema, index와 query-plan 설계 + +### 24.1 schema naming + +- application-owned explicit schema를 사용한다. +- runtime role의 `search_path`는 trusted `pg_catalog, `로 고정하고 + startup borrowed connection마다 검증한다. `"$user"`나 untrusted writable schema를 넣지 + 않는다. +- `PUBLIC`과 runtime role의 `public` schema `CREATE`를 revoke하고 runtime role에는 application + schema `CREATE`/owner 권한을 주지 않는다. 필요하지 않으면 database `TEMP`도 부여하지 않는다. +- Hibernate `default_schema`를 application schema로 고정하고 native SQL은 schema-qualified + object를 사용한다. function/operator 호출도 allowlist/schema qualification을 적용한다. +- extension은 migration role이 승인된 trusted schema에 설치하며 `public`에 암묵적으로 생기는 + 것을 피한다. +- table/column/index/constraint 이름은 lower snake case와 bounded length를 사용한다. +- reserved keyword와 quoted mixed-case identifier를 피한다. + +startup/security integration test는 writable-schema injection과 동일 이름 function/table +shadowing을 시도해 runtime query가 공격자 object를 resolve하지 않는지 검증한다. + +### 24.2 index는 query/constraint에서 파생 + +각 index에는 owner query/constraint가 있어야 한다. + +```text +query predicate equality columns +-> range/sort columns +-> stable tie-breaker +-> optional INCLUDE projection columns +``` + +multicolumn index의 column order를 “selectivity가 높은 순” 하나의 규칙으로 결정하지 않는다. +실제 predicate, ordering, prefix usability를 본다. + +### 24.3 unique + +- business uniqueness는 named unique constraint/index로 표현한다. +- nullable unique semantics를 명시한다. +- soft delete가 있으면 active-row partial unique index를 고려한다. +- tenant mode에서는 tenant key가 uniqueness scope에 포함된다. +- case-insensitive uniqueness는 normalization authority와 collation을 명시한다. + +application normalize와 DB expression/collation이 다르면 correctness가 깨지므로 canonical +normalization test를 둔다. + +### 24.4 foreign key + +- aggregate delete/cascade semantics를 domain lifecycle과 맞춘다. +- broad `ON DELETE CASCADE`를 편의 기본값으로 쓰지 않는다. +- FK source column에 필요한 index를 query/delete workload 기준으로 검토한다. +- cyclic FK와 deferrable constraint는 별도 transaction semantics가 있을 때만 사용한다. + +### 24.5 partial/covering/expression index + +PostgreSQL-specific index는 `.postgresql` migration/query card가 소유한다. + +- predicate가 query와 논리적으로 일치하는지; +- INCLUDE가 write/storage 비용보다 이득인지; +- expression normalization이 application과 같은지; +- index-only scan이 visibility map과 workload에서 실제로 가능한지 + +를 real plan/test로 검증한다. + +### 24.6 representative plan evidence + +`EXPLAIN` test는 전체 textual plan snapshot을 brittle하게 고정하지 않는다. 대표적인 +production-scale fixture/statistics에서 구조 invariant를 검증한다. + +예: + +- forbidden sequential scan on large selective table; +- expected index 또는 bitmap path; +- bounded estimated/actual rows; +- no disk sort above threshold; +- no nested loop explosion; +- query completes within generous CI budget. + +`EXPLAIN ANALYZE`는 실제 query를 실행하므로 destructive/mutating statement에 무심코 사용하지 +않는다. fixture scale과 statistics drift를 versioned test data로 관리한다. + +### 24.7 plan change governance + +- query ID별 representative plan artifact; +- PostgreSQL/Hibernate/driver upgrade 전 comparison; +- index 추가/제거의 write/read impact; +- `ANALYZE`/statistics requirement; +- parameter skew와 generic/custom plan 위험; +- production slow query observation; +- rollback/roll-forward index plan. + +plan text가 달라졌다는 이유만으로 실패시키지 않고, 성능/correctness invariant가 깨질 때 +실패시킨다. + +## 25. Tenant isolation + +### 25.1 기본 profile + +template 기본은 `tenantMode=NONE`이다. tenant concept를 모든 새 project에 억지로 넣지 않는다. +활성화 시 discriminator를 baseline으로 한다. + +### 25.2 discriminator invariant + +- 모든 tenant-owned row에 non-null `tenant_id`; +- 모든 repository/query predicate에 tenant key; +- primary key 또는 lookup index prefix에 tenant requirement 반영; +- 모든 business unique constraint에 tenant key; +- FK가 다른 tenant row를 참조하지 못하도록 composite key/FK 또는 별도 validation; +- outbox/idempotency/inbox scope에 tenant; +- cursor/filter fingerprint에 tenant context; +- cache/object/message key에도 동일 canonical tenant authority. + +tenant ID는 inbound request body에서 신뢰하지 않고 authenticated application context에서 +얻는다. + +### 25.3 enforcement + +repository developer의 기억만으로 tenant predicate를 보장하지 않는다. + +- tenant-aware base collaborator 또는 query builder; +- integration test에서 cross-tenant fixture; +- native query review/check; +- query catalog tenant flag; +- schema constraint; +- optional RLS defense-in-depth. + +generic JPA filter가 bulk/native SQL과 maintenance를 모두 자동 보호한다고 주장하지 않는다. + +### 25.4 RLS optional profile + +RLS를 사용하면: + +- runtime role은 table owner, superuser, `BYPASSRLS`가 아니다. +- 필요한 table에 `ENABLE`과 `FORCE ROW LEVEL SECURITY`를 검토한다. +- transaction-local tenant setting을 첫 query 전에 parameterized하게 설정한다. +- pool 반환 시 session state가 남지 않는다. +- migration/maintenance role은 별도다. +- missing tenant context는 empty result가 아니라 fail-closed가 되어야 한다. +- partition, FK, unique, background job, backup/restore를 test한다. +- startup role probe와 negative integration test가 owner/superuser/`BYPASSRLS` 우회를 각각 + 거절한다. + +RLS만으로 encryption, authorization, tenant-aware uniqueness를 대신하지 않는다. + +### 25.5 schema/database per tenant + +schema-per-tenant와 database-per-tenant는 tenant 수, migration fan-out, pool explosion, +provisioning/backup/legal isolation 요구가 실제로 있을 때 별도 capability로 설계한다. 이번 +R2 baseline에 넣지 않는다. + +## 26. Security, secret와 privacy + +### 26.1 transport security + +production PostgreSQL connection은 TLS와 hostname/certificate verification을 사용한다. +pgjdbc의 `sslmode=verify-full` 또는 deployment가 동등하게 검증한 설정을 canonical로 한다. +`require`만으로 hostname verification까지 되었다고 주장하지 않는다. + +trust material: + +- secret reference/volume로 주입; +- repository, image, example에 secret 없음; +- permission 최소화; +- rotation 절차; +- expiry metric/alert; +- 새 connection 검증 뒤 old pool drain. + +### 26.2 role 분리 + +| Role | 권한 | +| --- | --- | +| migration | DDL과 승인된 migration DML | +| runtime-primary | 필요한 schema DML/sequence execute | +| runtime-replica | read only | +| monitoring | 승인된 statistics/health view | +| break-glass admin | 평시 application에서 미사용, audit | + +runtime role에 schema owner/superuser/replication 권한을 주지 않는다. production application은 +Flyway migration credential를 상시 보유하지 않는 external-job mode를 우선한다. + +### 26.3 SQL injection과 identifier + +- value는 bind parameter; +- sort/table/column/function은 allowlisted enum에서만 선택; +- native query string에 client input concat 금지; +- LIKE pattern escaping 의미를 명시; +- full-text/search extension query도 typed builder 사용; +- migration placeholder에 untrusted runtime input 금지. + +### 26.4 data classification + +entity/column/query card에 classification을 둔다. + +```text +PUBLIC +INTERNAL +CONFIDENTIAL +RESTRICTED +``` + +RESTRICTED data: + +- log/trace/query parameter 미기록; +- 최소 projection; +- retention/delete owner; +- backup 포함 암호화; +- access audit; +- lower environment masking/synthetic fixture; +- support dump redaction. + +database encryption-at-rest는 application logging/authorization/column exposure 문제를 해결하지 +않는다. field-level encryption이 필요하면 query/index/key rotation과 domain ownership을 별도 +설계한다. + +### 26.5 error privacy + +client: + +- stable application error code; +- safe message; +- correlation ID. + +server diagnostic: + +- query ID; +- SQLState; +- semantic constraint ID; +- route/pool; +- transaction phase; +- retry/outcome; +- sanitized exception class. + +SQL text, bind value, JDBC URL credential, raw tenant/principal/idempotency key는 제외한다. + +## 27. Configuration design + +### 27.1 activation SSOT + +권장 canonical shape: + +```yaml +ca-skeleton: + capabilities: + persistence: + provider: jpa-postgresql + idempotency: + provider: jdbc + guarantee: same-store-transactional + outbox: + dispatch-mode: polling + inbox: + provider: disabled + lock: + provider: disabled + + providers: + jpa-postgresql: + implementation-version: jpa-postgresql-v2 + migration-mode: external-job + schema-compatibility: n-and-n-minus-1 + tenant-mode: none + read-routing: primary-only + primary: {} + replica: {} + jdbc-coordination: + guarantee: efficiency-only +``` + +provider/mode 선택의 유일한 SSOT는 상위 platform 설계의 +`ca-skeleton.capabilities.*.provider`와 `outbox.dispatch-mode`다. JPA provider subtree는 +선택된 provider의 tuning/schema implementation version만 가지며 별도 `enabled`나 `mode`로 +다시 활성화하지 않는다. V1/V2는 activation 선택이 아니라 descriptor와 rolling schema +compatibility를 위한 implementation version이다. 선택 값과 provider subtree가 불일치하면 +startup을 실패시킨다. card별 schema revision을 operator configuration scalar로 복제하지 +않는다. §31.3 registry의 selected card, `schema-stream`, migration location/history, +required core epoch와 feature revision이 유일한 schema-capability SSOT다. compiled runtime +descriptor와 immutable migration evidence는 그 metadata를 그대로 담는다. 따라서 CDC +selection에는 storage revision과 external messaging manifest만 있고 polling delivery +revision은 없으며, polling selection에는 storage와 polling revision이 각각 존재한다. + +Spring `spring.datasource.*`, `spring.jpa.*`, `spring.flyway.*`를 자유로운 외부 public contract로 +노출하는 대신 typed settings가 canonical env를 검증하고 필요한 framework properties를 +composition한다. migration 기간의 legacy env alias는 충돌 시 fail-fast한다. + +### 27.2 typed settings group + +최소 그룹: + +```text +JpaCapabilitySettings +PrimaryDataSourceSettings +ReplicaDataSourceSettings +PoolSettings +TransactionPolicySettings +PostgreSqlTimeoutSettings +MigrationSettings +QueryGuardSettings +TenantSettings +IdempotencyJpaSettings +OutboxJpaSettings +InboxJpaSettings +JdbcCoordinationSettings +``` + +string map으로 arbitrary policy를 받기보다 validated record와 allowlisted policy catalog를 +사용한다. + +### 27.3 startup validation + +활성 profile에서 다음을 fail-fast한다. + +- persistence provider가 `jpa-postgresql`이 아니거나 capability/provider subtree가 불일치; +- primary URL/credential/role/schema 누락; +- production에서 `ddl-auto=update/create/create-drop`; +- OSIV true 또는 canonical setting 누락; +- pool size <= 0 또는 deployment budget 초과; +- invalid/unknown Duration; +- validation timeout >= connection timeout; +- pool `connectionTimeout`이 수용 policy의 최소 acquisition budget보다 큼; +- policy 최소 remaining budget이 admission + connection acquire + begin + action + + completion window를 담지 못함; +- keepalive >= max lifetime; +- transaction/statement/lock/deadline hierarchy 위반; +- replica policy 활성인데 replica datasource/lag probe 없음; +- primary endpoint가 read-only; +- replica endpoint가 writable primary; +- migration external mode인데 checksum/schema epoch/feature revision/object compatibility 중 하나가 + 불충족; +- selected card별 code descriptor와 registry의 schema stream/core epoch/feature revision, + applied migration evidence가 불일치; +- CDC selected인데 polling delivery revision이 광고되거나 external messaging manifest가 + 없고, polling selected인데 CDC manifest가 광고됨; +- selected outbox mode와 DB publication control의 active epoch/authority, immutable cutover + sentinel이 불일치하거나 target cutover 뒤 runtime role에 legacy table mutation 권한이 남음; +- tenant RLS mode인데 runtime role/tenant context 검증 실패; +- duplicate SQLState/constraint mapping; +- deprecated/canonical config 동시 설정; +- sample migration이 production artifact에 포함됨. + +### 27.4 disabled zero-side-effect + +현재 application artifact는 JPA primary persistence가 필수다. +`capabilities.persistence.provider=disabled`는 DB-free composition card와 composition test가 +구현되기 전에는 “DB 없이 기동”이 아니라 startup failure다. + +`read-routing=primary-only`, `inbox.provider=disabled`, `lock.provider=disabled`, +`outbox.dispatch-mode=disabled`처럼 optional sub-card가 disabled이면 그 sub-card는: + +- 해당 replica/provider datasource/pool을 만들지 않는다. +- 해당 endpoint DNS/connection을 시도하지 않는다. +- scheduler/reaper를 등록하지 않는다. +- disabled card 전용 migration/worker를 실행하지 않는다. never-installed card는 전용 + history/schema object도 만들지 않고, previously-installed card는 §23.3의 + `INSTALLED_INACTIVE` schema를 파괴하지 않는다. +- disabled card의 repository scan/bean side effect를 만들지 않는다. +- required semantic port가 없으면 composition이 명시적으로 실패하거나 feature use case 자체가 + 비활성화된다. + +미래 DB-free artifact가 추가되면 전체 persistence provider disabled에서 primary +datasource/Flyway/entity scan이 모두 zero-side-effect임을 별도 artifact/composition test로 +증명한 뒤에만 그 보장을 descriptor에 추가한다. + +### 27.5 environment registry + +새 env key는 repository의 env registry와 `verifyEnvKeys`를 함께 갱신한다. + +- canonical key; +- type/unit; +- safe default 또는 required; +- secret 여부; +- environment별 example; +- deprecated alias와 제거 release; +- owning settings class; +- validation rule + +를 기록한다. secret의 실제 값은 example/fixture에 넣지 않는다. + +## 28. Bootstrap, readiness와 health + +### 28.1 startup sequence + +`EXTERNAL_JOB` production: + +```text +resolve secrets +-> validate typed settings +-> create primary pool +-> probe connectivity + writable role + TLS +-> validate Flyway checksum + schema epoch/range + feature revision + critical objects +-> optional replica pool/probe/lag capability +-> validate transaction/query/failure catalogs +-> expose readiness +-> accept traffic +``` + +`STARTUP` local: + +```text +resolve/validate +-> create migration datasource +-> migrate +-> create/validate runtime +-> readiness +``` + +migration datasource와 runtime datasource가 같은 pool/credential이어야 한다고 가정하지 않는다. + +### 28.2 liveness + +liveness는 database availability에 의존하지 않는다. DB 장애가 pod restart storm을 만들지 않게 +process/event-loop 상태만 본다. + +### 28.3 readiness + +write-serving readiness: + +- primary pool initialized; +- writable primary role; +- required Flyway checksum/schema epoch/feature revision/object compatibility; +- critical settings/catalog validation; +- admission runtime active. + +replica: + +- optional query면 degraded indicator; +- required query profile이면 해당 route readiness; +- lag unknown/over-bound를 bounded-staleness ready로 보지 않는다. + +readiness query는 request pool을 고갈시키지 않고 low-cost/bounded여야 한다. + +### 28.4 startup failure와 retry + +orchestrator가 restart/backoff를 소유하는 production profile에서는 application 내부 무한 +startup retry를 하지 않는다. finite bootstrap budget 뒤 명확히 실패한다. transient secret/ +DNS/database startup ordering이 필요한 local compose는 bounded retry를 별도 profile로 둔다. + +### 28.5 shutdown/quiesce + +```text +readiness false +-> 신규 request/maintenance claim 중단 +-> in-flight request budget 안에서 drain +-> outbox/inbox/idempotency worker claim 중단 +-> owner lease가 끝나거나 safe release +-> pools close +``` + +connection pool을 먼저 닫아 active transaction을 indeterminate로 만들지 않는다. drain timeout +초과 시 active transaction/claim 수를 기록하고 강제 종료의 중복 가능성을 runbook에 남긴다. + +## 29. Observability + +### 29.1 metric naming 원칙 + +기존 repository metric registry를 SSOT로 사용하고 새 metric은 registry와 instrumentation을 +같이 추가한다. metric tag는 bounded enum만 사용한다. + +기존 registry 이름을 같은 의미의 새 이름으로 복제하지 않는다. + +| Metric | 처리 | 안전한 tags | +| --- | --- | --- | +| `db.query.duration` | 기존 이름 재사용 | existing bounded `operation`, `outcome` | +| `hikaricp.connections.acquire` | 기존 이름 재사용 | `pool`, `outcome` | +| `hikaricp.connections.usage` | 기존 이름 재사용 | `pool` | +| `hikaricp.connections.active` | 기존 이름 재사용 | `pool` | +| `outbox.pending.size` | 기존 이름/상태 migration | bounded `status` | +| `db.transaction.duration` | 신규 registry 후 사용 | `policy`, `outcome` | +| `db.transaction.retry` | 신규 registry 후 사용 | `policy`, `reason` | +| `db.transaction.indeterminate` | 신규 registry 후 사용 | `policy`, `phase` | +| `db.query.rows` | 신규 registry 후 사용 | bounded query catalog/class, `route` | +| `db.query.timeout` | 신규 registry 후 사용 | bounded query catalog/class, `route`, `cause` | +| `db.optimistic.conflict` | 신규 registry 후 사용 | bounded aggregate catalog ID | +| `db.lock.timeout` | 신규 registry 후 사용 | bounded operation catalog ID | +| `db.replica.fallback` | 신규 registry 후 사용 | bounded query catalog ID, `reason` | +| `db.replica.lag` | 신규 registry 후 사용 | configured replica logical ID | +| `db.migration.duration` | 신규 registry 후 사용 | `mode`, `outcome`, bounded schema epoch bucket | +| `db.idempotency.owner_mismatch` | 신규 registry 후 사용 | bounded operation catalog ID | +| `db.inbox.redelivery` | 신규 registry 후 사용 | bounded handler catalog ID | + +새 metric/tag/allowed value는 `docs/registries/metrics.yaml`, instrumentation, cardinality contract, +alert mapping을 같은 change에서 추가한다. alias/deprecation migration 없이 +`db.operation.duration`, `db.pool.*`, `db.outbox.pending` 같은 중복 이름을 만들지 않는다. +exact migration version은 계속 증가하므로 metric tag가 아니라 structured event/evidence +artifact에 둔다. + +`operationCatalogId`/`queryCatalogId`는 startup에 등록된 작은 allowlist이고, +reconciliation용 per-request `operationInstanceId`/`OperationId`와 다른 타입과 이름을 사용한다. +instance ID는 metric tag에 절대 넣지 않는다. + +금지 tag: + +- SQL text; +- table/column/constraint raw name; +- entity ID; +- tenant/user/client ID; +- idempotency key; +- message/event ID; +- operation instance/reconciliation ID; +- JDBC URL/host. + +### 29.2 trace + +span 예: + +```text +db.transaction +db.query +db.outbox.claim +db.outbox.complete +db.idempotency.claim +db.inbox.handle +db.migration.validate +``` + +attribute: + +- bounded semantic operation/query catalog ID; +- policy; +- route; +- attempt; +- outcome; +- row-count bucket; +- timeout bucket. + +OpenTelemetry의 database semantic convention을 사용하되 parameter와 full statement recording은 +privacy 정책으로 제한한다. + +### 29.3 structured log + +주요 event: + +- startup capability descriptor; +- schema incompatibility; +- role mismatch; +- pool exhaustion; +- commit indeterminate; +- retry exhausted; +- slow query threshold; +- owner mismatch; +- outbox DEAD/head blocked; +- replica lag/fallback; +- migration failure; +- shutdown drain timeout. + +동일 장애의 stack trace/log storm을 sampling/rate limit한다. client-visible correlation ID로 +server event를 찾을 수 있게 한다. + +### 29.4 alert/SLO + +alert 후보: + +- pool pending/acquire p95/p99와 timeout; +- transaction/query error ratio; +- commit-indeterminate > 0; +- deadlock/serialization retry 증가; +- outbox oldest pending age/dead count; +- idempotency owner mismatch; +- replica lag over policy; +- schema readiness failure; +- migration duration/failure; +- disk/WAL/connection saturation은 platform DB alert와 연결. + +경고 threshold는 load/capacity evidence에서 정한다. template 숫자를 production SLO로 +고정하지 않는다. + +## 30. Lifecycle, HA, backup과 disaster recovery + +### 30.1 HA 의미 + +client가 reconnect했다는 사실은: + +- 이전 transaction rollback; +- commit 여부; +- replica 최신성; +- prepared statement/session state 보존 + +을 뜻하지 않는다. failover 후 새 connection role/schema/timeout/tenant initialization을 다시 +검증한다. + +### 30.2 primary failover + +검증 scenario: + +- idle connection 중 failover; +- statement 실행 중; +- commit request 직전/중/직후; +- pool의 stale connection; +- DNS/endpoint 갱신; +- new primary role probe; +- replica route topology 변경; +- in-flight owner lease/outbox claim. + +commit 중 failover는 `COMMIT_INDETERMINATE`를 만들 수 있어 stable operation reconciliation이 +필수다. + +새 primary가 writable하다는 사실만으로 이전 authority의 acknowledged write가 존재한다고 +추정하지 않는다. provider RPO/durability와 authority timeline을 확인하고 RYW marker 및 +operation ledger를 reconcile한다. 조건을 충족하지 못하면 write가 사라지지 않았다는 보장이나 +RYW를 광고하지 않고 typed unavailable/indeterminate를 유지한다. + +### 30.3 backup + +R2 운영 문서는 다음을 명시한다. + +- backup 방식과 schedule; +- RPO/RTO; +- encryption/key ownership; +- retention/legal deletion; +- WAL/PITR 여부; +- schema/migration artifact 보관; +- object-storage response reference 등 외부 payload와의 일관성; +- restore target PostgreSQL version. + +backup job 성공만으로 복구 가능성을 주장하지 않는다. + +### 30.4 restore rehearsal + +R3 gate: + +```text +restore isolated environment +-> role/search_path/extension 확인 +-> Flyway validate +-> application N/N-1 compatibility smoke +-> integrity/reconciliation query +-> outbox/idempotency/inbox state 확인 +-> representative query plan +-> measured RPO/RTO 기록 +``` + +outbox terminal state와 external broker delivery, object-storage response reference는 +cross-store reconciliation이 필요하다. + +### 30.5 maintenance + +- vacuum/analyze/autovacuum visibility; +- long transaction; +- idle-in-transaction; +- table/index bloat; +- unused/duplicate index; +- sequence/ID capacity; +- partition lifecycle가 있다면 attach/detach; +- transaction ID age; +- schema lock wait + +를 platform DBA/managed-service observability와 연결한다. application이 DB maintenance engine을 +재구현하지 않는다. + +### 30.6 PgBouncer/proxy optional card + +도입 시 별도 qualification: + +- session/transaction pooling mode; +- prepared statement compatibility; +- SET LOCAL과 session state; +- server reset query; +- primary/replica endpoint; +- TLS 양 구간; +- auth/secret rotation; +- pool multiplication; +- failover; +- metrics/health. + +proxy가 있다고 application pool/admission이 불필요한 것은 아니다. + +## 31. Test, CI와 evidence design + +### 31.1 unit test + +application: + +- repository/query fake로 use case transaction intent; +- policy selection; +- retry eligibility; +- commit-indeterminate reconciliation; +- cursor/filter fingerprint; +- idempotency/outbox/inbox state transition. + +JPA leaf: + +- mapper round-trip/invariant failure; +- SQLState/constraint mapping duplicate fail-fast; +- exception cause-chain/phase classification; +- timeout calculation/rounding; +- settings validation; +- route context/nested rule; +- claim/complete affected-row handling; +- sensitive log/metric tag guard. + +### 31.2 JPA slice/integration + +H2를 PostgreSQL correctness evidence로 사용하지 않는다. 빠른 mapper/repository wiring test에 +쓸 수 있어도 다음은 real PostgreSQL에서만 검증한다. + +- native SQL; +- UUID/json/time semantics; +- isolation/lock; +- constraint name/SQLState; +- `SKIP LOCKED`; +- concurrent index/migration; +- query plan. + +### 31.3 real PostgreSQL task + +owner leaf 또는 명시적인 qualification source set에 다음 canonical task를 만든다. 이 목록은 +아래 card registry의 사람이 읽기 위한 projection이며 §34.1 표와 exact match해야 한다. + +```text +:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest +:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest +:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest +:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest +:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest +:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest +:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence +``` + +`postgresqlConcurrencyTest`, `postgresqlQueryPlanTest` 같은 helper suite를 추가할 수 있지만, +그 suite는 위 canonical task의 `dependsOn`/test-result input으로 명시적으로 매핑한다. helper +이름 자체를 readiness manifest의 producer로 사용하지 않는다. + +card/evidence SSOT는 구현 시 `src/config/jpa/readiness-cards.yaml`로 만들고 Gradle, +capability descriptor, evidence writer가 함께 읽는다. target registry shape는 다음과 같다. + +```yaml +schema-version: 1 +legacy-adoption: + state: transition-only + location: "db/migration/postgresql" + history-table: "flyway_schema_history" + immutable-applied-versions: [1, 3, 4, 5] + allowed-origin: LEGACY_ADOPTED +cards: + jpa-observability-lifecycle: + state: selected + schema-stream: none + prerequisites: [] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest" + required-evidence: [real-postgresql, lifecycle, observability, no-skip] + jpa-security-baseline: + state: selected + schema-stream: none + prerequisites: [jpa-observability-lifecycle] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest" + support-tasks: + - ":adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety" + - ":adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures" + required-evidence: [real-postgresql, tls, roles, namespace, redaction, no-skip] + jpa-flyway-migration: + state: selected + schema-stream: owned + prerequisites: [jpa-observability-lifecycle, jpa-security-baseline] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest" + required-evidence: [real-postgresql, migration, rolling-compatibility, no-skip] + migration: + location: "db/migration/jpa/core" + history-table: "flyway_jpa_core_history" + required-core-epoch: 0 + feature-revision: 1 + lifecycle-evidence: [fresh, legacy-adoption, interrupted-recovery] + jpa-transaction-runtime: + state: selected + schema-stream: none + prerequisites: [jpa-observability-lifecycle, jpa-security-baseline] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest" + required-evidence: [real-postgresql, concurrency, fault, no-skip] + jpa-aggregate-store: + state: selected + schema-stream: contributes-to-core + prerequisites: [jpa-transaction-runtime, jpa-flyway-migration] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest" + required-evidence: [real-postgresql, mapping, optimistic-conflict, no-skip] + jpa-query-model: + state: selected + schema-stream: contributes-to-core + prerequisites: [jpa-transaction-runtime, jpa-flyway-migration] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest" + required-evidence: [real-postgresql, query-contract, query-plan, no-skip] + jpa-primary-foundation: + state: selected + schema-stream: none + prerequisites: + - jpa-observability-lifecycle + - jpa-security-baseline + - jpa-flyway-migration + - jpa-transaction-runtime + - jpa-aggregate-store + - jpa-query-model + readiness-task: ":adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence" + support-tasks: + - ":adapter:outbound:persistence-jpa:test" + - ":app-bootstrap:test" + - ":verifyCleanArchitectureDependencies" + - ":verifyEnvKeys" + - ":verifyPublicPathSnapshot" + required-evidence: [architecture, configuration, base-card-manifests, no-skip] + jpa-idempotency-owner-safe-v2: + state: not-implemented + schema-stream: owned + prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest" + required-evidence: [real-postgresql, concurrency, fault, migration, stream-lifecycle, no-skip] + migration: + location: "db/migration/jpa/idempotency" + history-table: "flyway_jpa_idempotency_history" + required-core-epoch: 1 + feature-revision: 2 + lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery] + jpa-outbox-storage-v2: + state: not-implemented + schema-stream: owned + prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest" + dispatch-modes: [polling, cdc] + required-evidence: [real-postgresql, same-resource, partition-uniqueness, publication-authority-fence, legacy-writer-rejection, migration, stream-lifecycle, no-skip] + migration: + location: "db/migration/jpa/outbox-storage" + history-table: "flyway_jpa_outbox_storage_history" + required-core-epoch: 1 + feature-revision: 2 + lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery] + jpa-outbox-polling-delivery-v2: + state: not-implemented + schema-stream: owned + prerequisites: + - jpa-outbox-storage-v2 + - jpa-transaction-runtime + - jpa-flyway-migration + - jpa-observability-lifecycle + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest" + dispatch-modes: [polling] + required-evidence: [real-postgresql, concurrency, publish-fault, ordering, migration, stream-lifecycle, no-skip] + migration: + location: "db/migration/jpa/outbox-polling" + history-table: "flyway_jpa_outbox_polling_history" + required-core-epoch: 1 + feature-revision: 2 + lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery] + jpa-outbox-cdc-retention-v1: + state: not-implemented + schema-stream: none + prerequisites: [jpa-outbox-storage-v2, jpa-observability-lifecycle] + external-prerequisites: + - registry: "src/config/messaging/readiness-cards.yaml" + card-id: "messaging-cdc-dispatch.v1" + minimum-readiness: R2 + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest" + dispatch-modes: [cdc] + required-evidence: + - real-postgresql + - connector-checkpoint-high-watermark + - outage-restart + - replay-retention + - delete-tombstone-filtering + - mode-transition + - no-skip + jpa-inbox-same-store-v1: + state: not-implemented + schema-stream: owned + prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest" + required-evidence: [real-postgresql, redelivery, concurrency, migration, stream-lifecycle, no-skip] + migration: + location: "db/migration/jpa/inbox" + history-table: "flyway_jpa_inbox_history" + required-core-epoch: 1 + feature-revision: 1 + lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery] + jpa-primary-replica: + state: not-implemented + schema-stream: none + prerequisites: + - jpa-transaction-runtime + - jpa-query-model + - jpa-flyway-migration + - jpa-observability-lifecycle + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest" + required-evidence: [real-postgresql, replica, lag, failover, no-skip] + jpa-tenant-discriminator-rls: + state: not-implemented + schema-stream: owned + prerequisites: [jpa-primary-foundation] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest" + required-evidence: [real-postgresql, tenant-isolation, rls, migration, stream-lifecycle, no-skip] + migration: + location: "db/migration/jpa/tenant" + history-table: "flyway_jpa_tenant_history" + required-core-epoch: 1 + feature-revision: 1 + lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery] + jpa-jdbc-efficiency-coordination: + state: not-implemented + schema-stream: owned + prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle] + readiness-task: ":adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest" + required-evidence: [real-postgresql, contention, owner-safety, migration, stream-lifecycle, no-skip] + migration: + location: "db/migration/jpa/coordination" + history-table: "flyway_jpa_coordination_history" + required-core-epoch: 1 + feature-revision: 2 + lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery] +``` + +`state`는 `selected | implemented-candidate | not-implemented`만 허용한다. registry loader는 +unknown/missing ID, alias, duplicate task, cycle, selected card의 non-selected prerequisite, +존재하지 않는 selected readiness/support task를 fail-closed한다. schema-bearing card의 +`migration` 누락, duplicate location/history table, invalid core epoch/revision, lifecycle +evidence 누락도 실패한다. Flyway orchestrator/startup validator/evidence writer는 같은 +`migration` node를 읽는다. `schema-stream=owned`는 migration node가 필수, +`none`은 금지, `contributes-to-core`는 `jpa-flyway-migration` core manifest에 exact +resource/checksum contribution이 필수다. namespaced external prerequisite는 지정 registry의 exact card ID, +minimum readiness와 immutable manifest ID를 composition/release 시 검증하며 내부 card로 +조용히 대체하지 않는다. 이것은 bootstrap/release evidence edge이지 JPA leaf에서 messaging +leaf로 향하는 Gradle/project dependency가 아니다. readiness task는 선언된 support task와 +required evidence producer를 `dependsOn`으로 연결한다. registry key, §34 heading, descriptor +`cardReadiness` key, test `card-` tag와 evidence `cardId`는 byte-for-byte 같아야 한다. +release에서 CLI property로 selection/DAG를 덮어쓰지 않는다. §4.2와 §34 표는 이 registry의 +사람이 읽기 위한 projection이며 독립 SSOT가 아니다. + +outbox selection compiler는 `dispatch-mode=disabled`면 세 outbox card를 모두 non-selected, +`polling`이면 storage+polling만 selected, `cdc`면 storage+CDC만 selected로 만든다. CDC는 +namespaced messaging prerequisite가 없거나 R2 미만이면 fail-closed한다. polling과 CDC card를 +동시에 selected로 만든 registry/release assertion은 거절한다. + +여기서 `selected`는 target release가 반드시 검증해야 한다는 뜻이지 R2를 미리 부여한다는 +뜻이 아니다. registry/task가 아직 없는 현재 repository readiness는 §3의 R0/R1 판정을 +유지한다. implementation이 registry를 추가한 순간 selected task가 없거나 실패하면 release가 +fail-closed해야 한다. + +CI R2 lane에서는 Docker/Testcontainers/service가 없으면 skip하지 않고 실패한다. developer +local focused test는 환경 사유로 별도 task를 선택할 수 있지만 결과를 R2로 오인하지 않는다. + +### 31.4 transaction/concurrency matrix + +최소 scenario: + +- two writers optimistic conflict; +- unique check race; +- foreign/check/not-null constraint mapping; +- `READ_COMMITTED` non-repeatable observation documented; +- `REPEATABLE_READ` snapshot; +- `SERIALIZABLE` `40001` whole retry; +- deterministic deadlock and `40P01`; +- pessimistic lock timeout; +- statement timeout, explicit cancel, idle transaction timeout, `40003` unknown 분류; +- connection acquire exhaustion; +- `CallBudget`/Hikari/Spring timeout `999/1000/1001ms` boundary; +- Hikari가 `connectionTimeout` 가까이 기다린 뒤에도 first statement/total budget이 + overshoot되지 않는 real-DB test; +- nested REQUIRED route/timeout; +- 모든 outer가 inner를 요구하는 bounded `REQUIRES_NEW` reserve/deadlock barrier; +- rollback-only propagation; +- flush failure와 confirmed rollback; +- commit request 뒤 ACK 유실; +- legacy root write의 operation-ID 없는 indeterminate가 자동 replay되지 않고 + `INVOCATION_UNCORRELATED`로 관측됨; +- commit ACK 뒤 `afterCommit`/cleanup failure; +- `afterCompletion(ROLLED_BACK/COMMITTED/UNKNOWN)` outcome. + +### 31.5 idempotency/outbox/inbox concurrency + +- same scope concurrent claim one owner; +- expired `CLAIMED` takeover와 expired `EXECUTING` recovery-required; +- stale owner renew/complete/release no mutation; +- owner/attempt/operation/state-revision conflict; +- lease가 A business transaction 안에서 만료되는 동안 B takeover barrier와 business row + exactly-once mutation; +- request mismatch; +- bounded inline response compatibility; object-reference profile은 별도 card 전까지 R2 제외; +- crash before/after business commit; +- outbox multi-worker disjoint claim; +- stale claim reaper; +- late old owner completion; +- same aggregate concurrent writer와 `(aggregate_version, event_ordinal)` ordering; +- adjacent range partition에 같은 event ID 또는 aggregate tuple을 넣는 race와 identity guard + global uniqueness; +- 한 transition의 multiple event ID/ordinal/digest가 retry 간 stable; +- freeze 직전/도중 V1 append를 멈춘 barrier에서 legacy trigger의 `FOR SHARE` 때문에 cutover + `FOR UPDATE`가 추월하지 못하고, V1 commit 뒤 pending 재검증으로 cutover가 중단됨; +- cutover 후 paused/reconnected old writer의 V1 insert/status mutation이 ACL/trigger에서 + 거절되고 aggregate business transaction 전체가 rollback됨; +- V2 writer의 stale/future publication epoch 또는 authority mismatch가 business mutation과 + 함께 rollback되고, control update/legacy revoke/sentinel insert 중간 실패도 원자적으로 + 이전 authority를 보존함; +- fresh polling/CDC initialization이 epoch 1 control과 matching `GENESIS_FRESH` sentinel을 + 같은 transaction에 만들고, control-only/sentinel-only/epoch·authority·manifest mismatch + 상태에서 startup과 dispatcher를 거절함; +- polling/CDC mutual exclusion과 N/N+1 claim; +- CDC ordered destination의 stable `partition_key`/broker key mapping; +- CDC closed range partition의 connector checkpoint/high-watermark/replay-retention cleanup; +- connector outage/restart/snapshot cutover 동안 cleanup fail-closed와 delete/tombstone filtering; +- polling↔CDC mode 전환 backlog/offset/rollback runbook; +- DEAD head operator policy; +- publish success/DB ack loss duplicate; +- inbox redelivery before/after commit; +- polling delivery/inbox retention은 terminal timestamp, CDC retention은 checkpoint proof를 사용. + +### 31.6 query test + +- projection mapping; +- empty/max filter; +- max page/IN bound; +- stable keyset tie; +- cursor tamper/mismatch/version; +- N+1 statement upper bound; +- collection fetch/paging guard; +- count correctness; +- representative plan invariant; +- row/byte/time budget; +- slow-query recorder redaction. + +### 31.7 migration compatibility + +matrix: + +```text +schema S + application N-1 +schema S+1 + application N-1 +schema S+1 + application N +schema S+2 + application N during contract gate +``` + +scenario: + +- fresh migrate; +- migrate from every supported production baseline; +- checksum validate; +- accepted schema epoch/feature revision matrix와 startup negative cell; +- checksum, epoch, required object failure의 typed 구분; +- repeatable migration if any; +- concurrent startup/external job; +- failed nontransactional index and recovery; +- old application DML 중 DDL lock contention, finite timeout, forward recovery; +- transactional/nontransactional timeout 적용과 session reset; +- bridge/backfill restart; +- exact current V1/V3/V4/V5 history/object snapshot의 controlled adoption과 checksum/shape drift + rejection; +- 실제 V3 일반 `outbox_event` snapshot을 보존한 상태에서 별도 + `outbox_event_identity_v2`/`outbox_event_log_v2` 생성, V1-only drain, + matched `LEGACY_SHADOW` reconciliation, DB publication control/legacy mutation guard, + pre-cutover rollback과 forward-only cutover; +- legacy/target history dual-authority 금지, explicit version-0 baseline audit와 old migrator + retirement; +- independent optional Flyway stream의 fresh-disabled/first-enable/disable/re-enable lifecycle; +- outbox fresh polling/CDC stream의 epoch 1 control + origin별 genesis sentinel atomicity와 + control/sentinel partial state rejection; +- optional stream history/checksum/core-epoch prerequisite와 interrupted migration recovery; +- old/new enum/column writes; +- downgrade binary within declared window; +- contract after old usage zero. + +### 31.8 failure/HA + +- PostgreSQL unavailable before begin; +- server terminates connection during statement; +- proxy drops response during commit; +- delayed original commit와 same operation ledger replay arbitration; +- primary failover 전/중/후 authority timeline, RPO, reconciliation; +- replica unavailable/lag/role change와 qualification generation invalidation; +- multi-replica에서 qualified backend와 borrowed backend mismatch; +- bounded lag TTL 경계, query 직전 expiry, multi-statement 요청 거절; +- connect/begin failure, mid-query disconnect, 일부 row 소비 뒤 fallback 금지; +- RYW marker와 acknowledged-write loss 가능 failover; +- secret/certificate rotation; +- pool close during quiesce; +- application kill after claim/publish/commit points. + +fault injection이 실제 commit timing을 완전히 결정하지 못하면 evidence 한계를 기록하고 +operation-ID reconciliation outcome을 검증한다. + +### 31.9 security + +`postgresqlSecurityBaselineIntegrationTest`: + +- TLS hostname/certificate failure; +- wrong runtime role; +- schema/search_path/function/table shadow spoof; +- SQL identifier allowlist; +- log/trace/metric parameter redaction; +- secret absence/rotation; +- lower-environment fixture에 production PII 없음. + +이 readiness task는 `verifyJpaSqlConstructionSafety`와 `verifyJpaSecurityFixtures`를 +`dependsOn`한다. 첫 task는 native SQL concatenation, identifier/function/schema qualification, +bind/allowlist architecture rule을 검사한다. 둘째 task는 fixture provenance/PII denylist와 +sensitive-output negative corpus를 검사한다. test/task count 0, skipped/aborted, 결과 파일 누락은 +security manifest 생성 실패다. + +optional `postgresqlTenantRlsIntegrationTest`: + +- cross-tenant query/native/bulk/maintenance; +- RLS missing context/owner/superuser/`BYPASSRLS` bypass; +- pool reuse 뒤 tenant context 누출; +- tenant-scoped backup/restore/export/reaper. + +tenant/RLS test는 optional profile이 꺼진 base task에서 skip하지 않고 독립 card task에서만 +실행한다. + +### 31.10 performance/capacity + +R2 performance evidence: + +- target-like row cardinality와 data skew; +- pool sizes와 concurrent workload; +- p50/p95/p99 acquire/query/transaction latency; +- throughput; +- connection saturation; +- batch/fetch sizes; +- index/storage/write amplification; +- long transaction/vacuum impact; +- replica lag under write load; +- outbox backlog catch-up; +- memory/persistence-context bound. + +benchmark는 correctness test를 대체하지 않는다. CI threshold는 hardware 변동을 고려해 regression +budget으로 관리하고 production SLO는 별도 environment evidence를 사용한다. + +### 31.11 architecture/Gradle gate + +필수: + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +./gradlew \ + :adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest \ + :adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest \ + :adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest \ + :adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest \ + :adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest \ + :adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest \ + :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \ + --console=plain +./gradlew test --console=plain +./gradlew check --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew verifyPublicPathSnapshot --console=plain +./gradlew verifyEnvKeys --console=plain +``` + +새 real-service task가 구현되기 전에는 존재하지 않는 명령을 현재 통과 증거로 쓰지 않는다. +R2 implementation plan에서 task를 추가하고 `check` 또는 명시적 CI production lane에 +연결한다. + +### 31.12 evidence grade + +| Grade | 증거 | +| --- | --- | +| E0 | 문서/정적 추론 | +| E1 | unit/fake test | +| E2 | real single PostgreSQL integration | +| E3 | concurrency/fault/migration/plan matrix | +| E4 | target topology failover/restore/load rehearsal | + +R1은 E1 이상, R2는 해당 card의 E2/E3, R3는 E4가 필요하다. 다른 module/sample의 우연한 +test가 owner card의 evidence manifest를 대신하지 않는다. + +manifest는 card별로 분리하고 최소 `cardId`, prerequisite card/version/manifest ID, source +revision, canonical producer Gradle task/CI job, executed test count, skipped/aborted count, +no-skip sentinel 결과, PostgreSQL image digest/managed engine version, +pgjdbc/Hibernate/Flyway version, date, topology와 artifact location을 기록한다. + +schema-bearing card는 migration location/history table/core epoch/feature revision/stream lifecycle +evidence ID도 기록한다. outbox card는 `dispatchMode`를 필수로 기록하고 CDC면 connector/plugin +version, source/destination topology, external `messaging-cdc-dispatch.v1` manifest ID, +checkpoint/high-watermark와 cleanup evidence ID를 추가한다. polling evidence를 CDC로, storage +evidence를 delivery로 재사용하지 않는다. idempotency/outbox/inbox/replica evidence를 하나의 +“JPA integration passed” 행으로 합치지 않는다. `cardReadiness` descriptor는 이 immutable +manifest ID를 가리킬 때만 R2를 노출한다. + +## 32. Gradle, dependency와 split trigger + +### 32.1 registry + +`src/config/architecture/modules.json`이 source path, Gradle path, production dependency edge의 +SSOT다. 설계 구현 중 project edge가 필요해 보이면 먼저 registry와 architecture 의미를 +검토한다. + +### 32.2 production dependency + +JPA leaf가 소유할 수 있는 dependency: + +- Spring Data JPA/Hibernate; +- Spring JDBC/transaction integration; +- Hikari runtime integration; +- Flyway core와 PostgreSQL database support; +- pgjdbc; +- PostgreSQL-specific test tooling; +- application/shared/domain project edge는 registry 허용 범위 안. + +금지: + +- inbound-web; +- app-bootstrap; +- sample-portfolio; +- messaging/object-storage/fileserver adapter; +- Redis/Mongo provider SDK; +- domain/application에 대한 역방향 framework leakage. + +idempotency response object storage seam은 application/provider-neutral port로 호출하되 JPA leaf가 +object-storage adapter를 직접 의존하지 않는다. + +### 32.3 Testcontainers + +Testcontainers/PostgreSQL container dependency는 test scope에 둔다. image version/digest와 +reuse/parallelism을 CI 문서에 고정한다. Docker가 없을 때 assumption skip하는 task와 R2 +required task를 분리한다. + +### 32.4 leaf split trigger + +다음 중 실제 요구가 생기면 `persistence-jpa`와 `persistence-postgresql` split을 검토한다. + +- 두 번째 RDBMS를 같은 template에서 first-class 지원; +- PostgreSQL SDK/Flyway release와 vendor-neutral JPA release 독립 필요; +- 보안/라이선스/deployment boundary; +- vendor code가 공통 code보다 커져 review/ownership이 분리; +- application artifact가 JPA만 포함하고 PostgreSQL을 배제해야 함. + +split 전: + +- registry leaf 수와 edge; +- migration resource ownership; +- entity/repository scan; +- transaction manager/datasource composition; +- test fixture; +- bootstrap artifact + +를 설계한다. package 분리가 선행 seam이며 지금은 physical split하지 않는다. + +## 33. 단계별 migration + +### Phase 0 — Truthful baseline과 contract freeze + +- 현재 capability card/R1 evidence manifest 작성; +- production failure translator 미연결을 명시; +- SQLState mapping duplicate fail-fast; +- OSIV false와 production `ddl-auto` guard; +- Duration parser silent skip 제거 설계/test; +- existing API와 schema V1 compatibility freeze; +- real PostgreSQL task의 non-skippable lane 정의; +- outdated module/runbook name 정리; +- metrics registry와 실제 instrumentation drift 목록화. + +승격: 전체 R2가 아니라 truthful R1 baseline. + +### Phase 1 — Transaction/failure/deadline foundation + +- named transaction policy와 additive `TransactionPort`; +- `CallBudget` intersection; +- transaction-local statement/lock timeout; +- phase-aware transaction outcome; +- commit-indeterminate/reconciliation contract; +- common repository/query translation boundary; +- constraint allowlist; +- retry disposition와 whole-transaction executor; +- pool typed settings/admission/capacity validation; +- transaction/concurrency real PostgreSQL test. + +승격 상태: `jpa-transaction-runtime` R2 candidate. 이 phase의 기능 test만으로는 R2가 아니다. +§31.3 registry의 `jpa-observability-lifecycle`, `jpa-security-baseline` prerequisite와 자기 +evidence gate까지 통과한 뒤에만 manifest가 R2를 선언한다. + +### Phase 2 — Entity/query discipline + +- entity/mapping baseline 적용; +- aggregate repository와 query port 분리; +- projection/fetch plan; +- paging bound와 keyset cursor; +- query ID catalog; +- N+1 statement budget; +- representative PostgreSQL plan tests; +- batch/persistence-context bound; +- slow query recorder/redaction. + +승격 상태: `jpa-aggregate-store`, `jpa-query-model` R2 candidate. 두 card 모두 +`jpa-transaction-runtime`, `jpa-flyway-migration` prerequisite와 §34.1의 자기 task가 +통과하기 전에는 R2를 선언하지 않는다. + +### Phase 3 — Migration/operation hardening + +- external migration job production profile; +- schema compatibility validator; +- expand-contract/N/N-1 matrix; +- nontransactional index procedure; +- checkpointed backfill framework; +- migration/restore runbook; +- exact PostgreSQL/driver/ORM/Flyway version matrix; +- startup/readiness/shutdown lifecycle. + +승격 상태: `jpa-observability-lifecycle`, `jpa-security-baseline`, +`jpa-flyway-migration`의 prerequisite를 포함한 +base-card gate를 닫는 단계다. Phase 1–2 candidate도 §31.3 registry의 독립 manifest와 +`jpa-security-baseline` gate가 모두 통과한 것만 R2로 승격한다. `jpa-primary-foundation`은 여섯 base +card가 모두 R2인 뒤에만 R2가 된다. + +### Phase 4 — Owner-safe same-store reliability + +- JPA idempotency V2 owner token/CAS; +- bounded inline response와 cross-store response-reference 비보장 경계; +- outbox immutable identity/partitioned storage V2 + polling delivery V2; +- aggregate version/ordinal ordering과 DEAD-head operator policy; +- same-store inbox; +- maintenance command와 owner-safe reaper; +- V1 bridge/drain/contract migrations; +- publish/commit/ack failure injection tests. +- card별 task/CI/no-skip/image digest evidence manifest. + +승격 대상: `jpa-idempotency-owner-safe-v2`, `jpa-outbox-storage-v2`, +`jpa-outbox-polling-delivery-v2`, `jpa-inbox-same-store-v1` 중 §31.3 registry에서 selected이고 +자기 task/manifest가 통과한 card만 R2. CDC를 선택한 release는 polling card 대신 +`jpa-outbox-cdc-retention-v1`과 external `messaging-cdc-dispatch.v1`을 독립적으로 통과해야 +한다. + +### Phase 5 — Optional primary/replica + +- separate primary/replica pools; +- explicit consistency API; +- pre-transaction route context; +- endpoint-bound lag qualification/fallback policy; conservative oracle가 없으면 bounded profile + 비활성화; +- role/read-only probes; +- read-your-writes primary implementation; +- failover/lag/load tests; +- topology health/runbook. + +승격 대상: `jpa-primary-replica` card R2. 이 phase 전에도 `jpa-primary-foundation` R2는 +가능하다. + +### Phase 6 — Optional tenant/RLS와 coordination + +- tenant discriminator schema/query/unique/FK; +- cross-tenant architecture/integration tests; +- optional FORCE RLS/runtime role/context; +- JDBC lock owner-safe release와 efficiency evidence; +- fencing이 필요하면 별도 contract/provider 설계; +- retention/privacy/backup alignment. + +승격 대상: `jpa-tenant-discriminator-rls`, `jpa-jdbc-efficiency-coordination` 중 활성화하고 +자기 task/manifest를 통과한 card만 R2. + +### Phase 7 — R3 rehearsal + +- target-like load/capacity; +- primary failover; +- rolling migration; +- secret/certificate rotation; +- backup restore/PITR; +- outbox/inbox/idempotency reconciliation; +- measured RPO/RTO와 SLO; +- operator game day evidence. + +## 34. 완료 기준 + +### 34.1 JPA primary foundation canonical gate + +base card의 ID, dependency와 구현 시 추가할 non-skippable Gradle task는 다음 표와 같다. +machine-readable 정본은 §31.3의 `src/config/jpa/readiness-cards.yaml`이다. +아래 task는 현재 존재하는 통과 증거가 아니라 R2 implementation plan이 생성하고 production +CI lane에 연결해야 할 target이다. + +| Card ID | Direct prerequisite | Required non-skippable task | +| --- | --- | --- | +| `jpa-observability-lifecycle` | 없음 | `:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest` | +| `jpa-security-baseline` | `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest` | +| `jpa-flyway-migration` | `jpa-observability-lifecycle`, `jpa-security-baseline` | `:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest` | +| `jpa-transaction-runtime` | `jpa-observability-lifecycle`, `jpa-security-baseline` | `:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest` | +| `jpa-aggregate-store` | `jpa-transaction-runtime`, `jpa-flyway-migration` | `:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest` | +| `jpa-query-model` | `jpa-transaction-runtime`, `jpa-flyway-migration` | `:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest` | +| `jpa-primary-foundation` | `jpa-observability-lifecycle`, `jpa-security-baseline`, `jpa-flyway-migration`, `jpa-transaction-runtime`, `jpa-aggregate-store`, `jpa-query-model` | `:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence` | + +card readiness는 prerequisite보다 높을 수 없다. 각 required task는 executed test가 0이거나 +skipped/aborted가 하나라도 있으면 실패해야 한다. `jpa-primary-foundation` task는 여섯 +immutable base manifest와 아래 common architecture manifest를 검증하는 +aggregation gate이지, 하위 test를 한 개의 불투명한 “JPA passed” 행으로 합치는 대체 증거가 +아니다. + +#### 34.1.1 `jpa-observability-lifecycle` R2 + +- [ ] typed settings가 잘못된 Duration/capacity 조합을 silent skip하지 않고 fail-fast한다. +- [ ] fixed Hikari acquisition timeout, pool capacity equation과 admission이 target + deployment에 맞게 검증된다. +- [ ] startup/readiness/shutdown과 pool drain이 real PostgreSQL에서 검증된다. +- [ ] metrics/traces/log가 cardinality-bounded이며 SQL value, credential, endpoint를 + redaction한다. +- [ ] lifecycle/alert/runbook과 immutable evidence manifest가 있다. +- [ ] `postgresqlLifecycleIntegrationTest`가 zero-skip로 통과한다. + +#### 34.1.2 `jpa-security-baseline` R2 + +- [ ] `postgresqlSecurityBaselineIntegrationTest`가 TLS hostname mismatch, + expired/untrusted certificate와 revoked credential을 real PostgreSQL에서 거절한다. +- [ ] production은 pgjdbc `sslmode=verify-full` 또는 hostname과 trust chain을 동등하게 + 검증하는 deployment control을 사용하고 secret/certificate rotation을 검증한다. +- [ ] migration/runtime role이 분리되고 runtime role은 least privilege이며 + owner/superuser/`BYPASSRLS`가 아니다. +- [ ] runtime `search_path`는 trusted schema로 고정하고 untrusted schema와 `public`의 + `CREATE`를 revoke하며 startup catalog probe와 shadow-spoof negative test가 실제 값을 + 검증한다. +- [ ] PostgreSQL의 default `PUBLIC TEMPORARY` privilege를 runtime에서 revoke한다. 임시 + relation이 필요한 별도 profile은 모든 relation schema qualification과 `pg_temp` + shadow negative evidence 없이는 활성화하지 않는다. +- [ ] value는 bind하고 identifier/sort/function은 allowlist/schema qualification을 사용하며 + native SQL string concatenation architecture test가 통과한다. +- [ ] client error, log/trace/metric/evidence artifact가 SQL value, constraint/raw server + detail, password/token/certificate/JDBC URL secret, host/database/user를 노출하지 않는다. +- [ ] lower-environment fixture에 production PII가 없고 redaction negative corpus가 통과한다. +- [ ] zero-skip task 결과와 immutable security manifest가 있다. + +#### 34.1.3 `jpa-flyway-migration` R2 + +- [ ] production OSIV false와 Hibernate schema update 금지가 fail-fast한다. +- [ ] Flyway external/startup mode, checksum, schema epoch와 feature compatibility가 + 명확하다. +- [ ] expand-contract, N/N-1 rolling matrix와 finite lock/statement timeout이 검증된다. +- [ ] 현재 V1/V3/V4/V5 history/object에서 controlled adoption, explicit baseline audit와 + legacy/target dual-authority rejection이 검증된다. +- [ ] nontransactional DDL과 checkpointed backfill의 recovery procedure가 있다. +- [ ] restore/forward-recovery runbook과 immutable evidence manifest가 있다. +- [ ] `postgresqlMigrationIntegrationTest`가 zero-skip로 통과한다. + +#### 34.1.4 `jpa-transaction-runtime` R2 + +- [ ] application-owned named transaction policy와 policy/consistency admission이 있다. +- [ ] fixed Hikari acquisition timeout과 dynamic `CallBudget` pre-gate를 포함해 + deadline/transaction/statement/lock timeout이 finite하고 검증된다. +- [ ] phase-aware failure translation이 모든 production persistence path에 연결된다. +- [ ] physical owner와 participating `REQUIRED` outcome이 구분되고 commit-indeterminate가 + blind retry되지 않는다. +- [ ] named write policy는 stable operation ID를 요구하고 legacy facade는 별도 + non-replayable/uncorrelated risk와 migration count를 노출한다. +- [ ] optimistic conflict와 allowlisted constraint가 typed 결과다. +- [ ] isolation/deadlock/timeout/pool exhaustion/commit uncertainty real-DB test가 있다. +- [ ] `postgresqlTransactionIntegrationTest`가 zero-skip로 통과하고 immutable manifest를 + 남긴다. + +#### 34.1.5 `jpa-aggregate-store` R2 + +- [ ] domain aggregate와 persistence entity가 분리되고 mapper에 business policy가 없다. +- [ ] aggregate root/version/child ownership과 optimistic conflict contract가 검증된다. +- [ ] write transaction의 constraint/flush/commit failure가 공통 translator를 통과한다. +- [ ] batch와 persistence-context size가 bounded다. +- [ ] `postgresqlAggregateIntegrationTest`가 zero-skip로 통과하고 immutable manifest를 + 남긴다. + +#### 34.1.6 `jpa-query-model` R2 + +- [ ] aggregate repository와 purpose-built query projection port가 분리된다. +- [ ] fetch plan, N+1 statement budget, page limit와 keyset cursor가 bounded다. +- [ ] query ID catalog와 representative PostgreSQL plan regression test가 있다. +- [ ] consistency/source marker가 실제 transaction route와 일치한다. +- [ ] `postgresqlQueryIntegrationTest`가 zero-skip로 통과하고 immutable manifest를 남긴다. + +#### 34.1.7 `jpa-primary-foundation` aggregation R2 + +- [ ] module registry, Gradle dependency verification과 architecture test가 통과한다. +- [ ] core에 JPA/Spring/transport type이 없고 controller가 persistence type/repository를 + 직접 사용하지 않는다. +- [ ] `jpa-observability-lifecycle`, `jpa-security-baseline`, `jpa-flyway-migration`, + `jpa-transaction-runtime`, `jpa-aggregate-store`, `jpa-query-model`이 각각 자기 immutable + manifest로 R2이며 registry prerequisite DAG가 닫혔다. +- [ ] `verifyJpaPrimaryFoundationEvidence`가 base manifest ID, zero-skip sentinel, + `:adapter:outbound:persistence-jpa:test`, `:app-bootstrap:test`, + `:verifyCleanArchitectureDependencies`, `:verifyEnvKeys`, `:verifyPublicPathSnapshot` 결과를 + 검증하고 immutable bundle manifest를 남긴다. + +### 34.2 `jpa-idempotency-owner-safe-v2` R2 + +- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`, + `jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] Redis/JPA 공통 V2 state/result contract와 + owner/attempt/operation/state-revision CAS를 구현한다. +- [ ] stale owner transition이 no-op typed mismatch다. +- [ ] expired `EXECUTING`을 blind takeover하지 않고 inspect/reconcile한다. +- [ ] same-store business commit과 idempotency state의 transaction choreography가 검증된다. +- [ ] preclaim lease expiry/takeover barrier에서 business row가 정확히 한 번만 변경된다. +- [ ] DB transaction 안 object-storage I/O가 없고 R2 response는 bounded inline이다. +- [ ] fresh-disabled/enable/disable/re-enable/interrupted stream과 V1 migration/drain/contract가 + rolling-safe하다. +- [ ] `postgresqlIdempotencyIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable + manifest를 남긴다. + +### 34.3 `jpa-outbox-storage-v2` R2 + +- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`, + `jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] compact identity guard와 range-partitioned immutable event envelope가 분리된다. +- [ ] 기존 V3 `outbox_event`와 target `outbox_publication_control_v2`/ + `outbox_publication_cutover_v2`/`outbox_event_identity_v2`/ + `outbox_event_log_v2`의 물리 이름과 schema authority가 충돌하지 않는다. +- [ ] DB publication control이 정확히 한 active epoch/authority를 가지며 V2 append는 같은 + business transaction에서 이를 `FOR SHARE`로 잠그고 row epoch/authority를 검증한다. +- [ ] fresh polling/CDC 설치가 epoch 1 control과 exact origin/schema/external manifest를 가진 + `GENESIS_FRESH` sentinel을 한 migration transaction에서 만들고 partial/mismatch state를 + startup에서 거절한다. +- [ ] identity guard가 global event ID와 aggregate version/ordinal uniqueness를 보장하고 + partitioned event PK/FK는 partition key를 포함한다. +- [ ] adjacent partition의 duplicate event/aggregate tuple concurrency가 정확히 한 건만 + 성공한다. +- [ ] sequence authority가 aggregate version + deterministic ordinal이며 `MAX+1`을 쓰지 않는다. +- [ ] append는 active same-resource primary write transaction이 없으면 fail-fast한다. +- [ ] identity/event/optional delivery insert가 aggregate write와 같은 transaction이다. +- [ ] identity guard lifetime/capacity/backup/privacy와 payload partition lifecycle이 분리된다. +- [ ] V1-only row에 없는 ordering semantics를 조작해 backfill하지 않고, V1 drain, + matched-shadow reconciliation, pre-cutover rollback과 post-cutover forward recovery가 + 실제 V3 snapshot에서 검증된다. +- [ ] cutover의 control `FOR UPDATE`, legacy DML revoke, target authority update와 immutable + sentinel insert가 한 transaction이며 중간 실패는 이전 authority를 보존한다. +- [ ] freeze 직전/도중 paused V1 writer를 cutover가 추월하지 않고, cutover 후 + paused/reconnected old writer와 stale V2 epoch가 거절되며 business row도 commit되지 + 않는다. +- [ ] storage V2 adoption/contract와 optional stream lifecycle이 rolling-safe하다. +- [ ] `postgresqlOutboxStorageIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable + manifest를 남긴다. + +### 34.4 `jpa-outbox-polling-delivery-v2` R2 + +- [ ] `jpa-outbox-storage-v2`, `jpa-transaction-runtime`, `jpa-flyway-migration`, + `jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] immutable event와 mutable destination delivery가 분리된다. +- [ ] delivery의 composite PK/FK가 event retention/partition key를 포함한다. +- [ ] polling relay는 active `POLLING_V2` control/sentinel과 같은 epoch의 delivery만 claim한다. +- [ ] fresh polling genesis sentinel 누락/epoch·authority·manifest mismatch negative test가 + zero-skip로 실행된다. +- [ ] outbox completion이 owner/token/status를 검증한다. +- [ ] aggregate strict-order gate와 DEAD-head operator policy가 있다. +- [ ] broker ack loss와 duplicate publish test가 있다. +- [ ] polling/CDC activation이 상호 배타적이고 destination ordering descriptor가 truthful하다. +- [ ] polling retention만 `published_at`/`dead_at` terminal timestamp를 사용하고 identity + guard를 제거하지 않는다. +- [ ] polling delivery stream의 fresh-disabled/enable/disable/re-enable/interrupted migration과 + V1 drain/contract가 rolling-safe하다. +- [ ] `postgresqlOutboxPollingIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable + manifest를 남긴다. + +### 34.5 `jpa-outbox-cdc-retention-v1` R2 + +- [ ] `jpa-outbox-storage-v2`, `jpa-observability-lifecycle`과 external + `messaging-cdc-dispatch.v1` R2 immutable manifest가 exact prerequisite다. +- [ ] CDC mode에서 delivery row/polling scheduler가 없고 DB epoch/dispatch authority가 + polling과 상호 배타적이다. +- [ ] connector predicate가 active `CDC` control/sentinel epoch만 route하고 + `LEGACY_SHADOW`와 stale/future epoch를 제외한다. +- [ ] fresh CDC genesis sentinel 누락/epoch·authority·external manifest mismatch negative + test가 zero-skip로 실행된다. +- [ ] ordered destination의 non-null stable `partition_key`가 connector broker key와 같다. +- [ ] closed partition의 모든 destination에 대해 connector checkpoint/high-watermark coverage, + replay retention, incident/legal hold를 검증하고 evidence가 stale/unknown이면 cleanup을 + 거절한다. +- [ ] cleanup delete/detach/drop이 connector event/tombstone으로 route되지 않고 identity guard는 + 유지된다. +- [ ] connector outage/restart, snapshot cutover, partition growth와 polling↔CDC 전환 rehearsal가 + 있다. +- [ ] manifest가 `dispatchMode=cdc`, connector/plugin version/topology, external manifest ID와 + cleanup evidence ID를 기록한다. +- [ ] `postgresqlOutboxCdcCleanupIntegrationTest`가 zero-skip로 통과하고 exact card ID의 + immutable manifest를 남긴다. + +### 34.6 `jpa-inbox-same-store-v1` R2 + +- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`, + `jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] inbox claim/business write/completion이 같은 transaction이다. +- [ ] leased preclaim이면 business mutation 전 row lock/owner CAS를 하고 commit까지 유지한다. +- [ ] broker redelivery/commit uncertainty/takeover barrier가 business row exactly-once mutation을 + 검증한다. +- [ ] retention이 terminal timestamp를 사용한다. +- [ ] fresh-disabled/enable/disable/re-enable/interrupted stream과 V1 migration/drain/contract가 + rolling-safe하다. +- [ ] `postgresqlInboxIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable + manifest를 남긴다. + +### 34.7 `jpa-primary-replica` R2 + +- [ ] `jpa-transaction-runtime`, `jpa-query-model`, `jpa-flyway-migration`, + `jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] separate pool/role validation이 있다. +- [ ] application이 explicit `ReadConsistency`를 선택한다. +- [ ] existing transaction route가 downgrade되지 않는다. +- [ ] bounded staleness qualification이 endpoint/pool generation/role epoch와 실제 borrowed + backend에 결속되며, oracle이 없으면 비활성화된다. +- [ ] observed lag + monotonic elapsed + error margin upper bound와 single-statement 제한이 + 검증된다. +- [ ] fallback primary가 pre-transaction 또는 no-result replay-safe read로 제한되고 + explicit/observable하다. +- [ ] RYW가 authority timeline/RPO와 분리되어 failover 때 reconcile/fail-closed한다. +- [ ] failover/lag/load/secret rotation test가 있다. +- [ ] replica 장애와 application readiness 의미가 profile별로 정해졌다. +- [ ] `postgresqlReplicaIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable + manifest를 남긴다. + +### 34.8 `jpa-tenant-discriminator-rls` R2 + +- [ ] `jpa-primary-foundation` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] 모든 tenant-owned schema/query/index/unique/FK에 tenant scope가 있다. +- [ ] cross-tenant native/bulk/maintenance test가 있다. +- [ ] RLS runtime role이 owner/superuser/`BYPASSRLS`가 아니다. +- [ ] missing tenant context가 fail-closed다. +- [ ] pool session state 누출이 없다. +- [ ] backup/restore/export/reaper에도 tenant isolation이 유지된다. +- [ ] tenant stream의 fresh-disabled/enable/disable/re-enable/interrupted migration이 + rolling-safe하다. +- [ ] `postgresqlTenantRlsIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable + manifest를 남긴다. + +### 34.9 `jpa-jdbc-efficiency-coordination` R2 efficiency card + +- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`, + `jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다. +- [ ] descriptor가 `EFFICIENCY_ONLY`이며 correctness/fencing을 주장하지 않는다. +- [ ] acquire/renew/release가 owner/lease를 검증하고 stale release가 no-op이다. +- [ ] database predicate가 scheduler/reaper correctness를 독립적으로 보장한다. +- [ ] timeout, owner crash, lease loss, multi-instance contention test와 runbook이 있다. +- [ ] correctness lock이 필요한 consumer는 fenced contract/provider 없이는 composition이 + 실패한다. +- [ ] coordination stream의 checksum/core epoch와 + fresh-disabled/enable/disable/re-enable/interrupted migration이 검증된다. +- [ ] `postgresqlJdbcCoordinationIntegrationTest`가 zero-skip로 통과하고 exact card ID의 + immutable manifest를 남긴다. + +### 34.10 R3 + +- [ ] production-like failover/restore rehearsal가 있다. +- [ ] rolling application/schema upgrade와 rollback window가 검증된다. +- [ ] capacity/load와 SLO evidence가 있다. +- [ ] operator가 commit-indeterminate/outbox DEAD/migration failure를 실제 절차로 해결했다. +- [ ] evidence artifact의 date/version/topology가 추적된다. + +## 35. 금지된 주장 + +다음 문구는 해당 증거가 없으면 사용하지 않는다. + +- “JPA를 사용하므로 transaction-safe다.” +- “`@Transactional`이 exactly-once를 보장한다.” +- “connection error이므로 commit되지 않았다.” +- “retry했으므로 안전하다.” +- “read-only이므로 replica를 사용한다.” +- “replica가 거의 실시간이라 strong consistency다.” +- “optimistic lock이 모든 race를 막는다.” +- “`SKIP LOCKED`이 순서를 보장한다.” +- “JDBC lock이 distributed correctness lock이다.” +- “Flyway가 있으므로 zero-downtime migration이다.” +- “`ddl-auto=validate`가 rolling compatibility를 보장한다.” +- “Hikari 기본값이면 production pool sizing이 끝났다.” +- “virtual thread라 connection pool이 필요 없다.” +- “N+1은 lazy loading으로 해결된다.” +- “index가 있으므로 query가 빠르다.” +- “Testcontainers test가 skip되었지만 통과했다.” +- “outbox라서 메시지는 정확히 한 번 전달된다.” +- “idempotency key가 있으므로 command는 한 번만 실행된다.” +- “RLS를 켰으므로 tenant isolation이 완성됐다.” +- “backup이 있으므로 복구 가능하다.” +- “JPA leaf가 R2라 모든 capability card가 R2다.” + +## 36. 운영 runbook 요구 + +최소 문서: + +1. `db-startup-schema-incompatible` + - migration mode, schema version, checksum, role, safe forward fix. +2. `db-pool-exhaustion` + - active/pending/acquire latency, long transaction, capacity/admission, scale 주의. +3. `db-query-timeout` + - query ID, plan/statistics, lock vs statement, safe cancel. +4. `db-deadlock-serialization` + - SQLState, transaction policy, retry eligibility, lock order. +5. `db-commit-indeterminate` + - operation ID primary reconciliation, 절대 blind retry 금지. +6. `db-primary-failover` + - endpoint/role/pool refresh, indeterminate transaction, readiness. +7. `db-replica-lag` + - bound, fallback, traffic shedding, catch-up. +8. `db-migration-failure` + - transactional/nontransactional 구분, invalid index, forward fix. +9. `db-backfill-pause-resume` + - checkpoint, throttle, validation, contract gate. +10. `db-outbox-backlog-dead` + - oldest age, DEAD head, requeue/skip audit, duplicate risk. +11. `db-idempotency-stuck-owner` + - lease, owner mismatch, reconcile, response reference. +12. `db-inbox-redelivery` + - broker ack, DB transaction, message scope, DEAD. +13. `db-secret-certificate-rotation` + - new pool probe, drain, rollback. +14. `db-backup-restore` + - PITR target, application/schema validation, cross-store reconcile. +15. `db-jdbc-lock-timeout` + - 실제 table 이름/owner/lease와 efficiency-only 한계. + +runbook의 SQL은 read-only diagnostic을 기본으로 하고 destructive mutation/requeue/repair는 +precondition, expected affected rows, audit, recovery를 명시한다. 과거 migration을 수정하거나 +무조건 Flyway repair하는 절차를 제공하지 않는다. + +## 37. 알려진 위험과 구현 전 확인 사항 + +| 위험/질문 | 현재 판단 | 구현 전 필요한 증거 | +| --- | --- | --- | +| 선택한 phase-aware decorator/sentinel가 Spring lifecycle을 안정적으로 식별하는가 | §15.2 관측 지점을 정본으로 선택 | Spring transaction integration/fault test | +| `SET LOCAL` 적용이 JPA 첫 statement보다 항상 앞서는가 | 설계상 필수 | connection/transaction hook real DB test | +| Hikari와 application admission의 최적 크기 | deployment별 | target-like load/capacity test | +| replica lag source와 failover semantics | provider별 | managed service/topology contract | +| owner-safe idempotency UPSERT의 race | PostgreSQL native SQL 필요 가능 | concurrent takeover test | +| outbox aggregate strict ordering 비용 | destination별 선택 | backlog/head-of-line load test | +| sample migration location composition | customizer가 교체할 수 있음 | production/sample artifact test | +| query metric instrumentation | registry만 있고 recorder 불명확 | actual meter emission test | +| production slow query logging redaction | deferred | synthetic sensitive parameter test | +| JPA/Hibernate 7.1 upgrade plan drift | version-sensitive | ORM migration guide + full suite | +| exact PostgreSQL 16 minor/image | floating local tag | immutable CI/production version matrix | +| RLS와 connection pool state | optional, high risk | FORCE RLS/role/reset/failover test | +| PgBouncer prepared statement/SET LOCAL | topology-specific | proxy mode integration test | + +이 표는 설계 결정을 다시 열어 둔 목록이 아니라 선택한 계약을 R2로 승격하기 전 확인할 +implementation evidence다. 증거가 실패하면 문서의 보장을 낮추거나 별도 설계 변경을 승인받아야 +하며, 구현자가 임의 대안을 선택하지 않는다. 구현 계획은 각 항목을 task와 acceptance test로 +변환해야 한다. + +## 38. 구현 계획 작성 시 작업 분할 + +실제 구현은 한 PR/commit 범위로 몰지 않는다. 권장 독립 작업: + +1. baseline guard와 drift 수정; +2. transaction policy/deadline; +3. phase-aware failure translation; +4. pool typed settings/admission; +5. real PostgreSQL test source set; +6. query catalog/N+1/plan; +7. Flyway external job, legacy adoption과 optional stream compatibility; +8. idempotency V2; +9. outbox identity/partitioned storage V2; +10. polling delivery V2; +11. CDC retention과 external messaging evidence composition; +12. inbox; +13. replica; +14. tenant/RLS; +15. HA/restore evidence. + +각 작업은 owner leaf의 closest `CLAUDE.md`, registry path, focused test를 다시 확인하고 +test-first로 진행한다. architecture, runtime, data migration 경계가 바뀌면 별도 review를 +요청한다. + +## 39. Primary references + +### Spring + +- [Spring Framework — Programmatic Transaction Management](https://docs.spring.io/spring-framework/reference/data-access/transaction/programmatic.html) +- [Spring Data JPA 4.0 — Locking](https://docs.spring.io/spring-data/data-jpa/reference/4.0/jpa/locking.html) +- [Spring Data JPA 4.0 — Projections](https://docs.spring.io/spring-data/data-jpa/reference/4.0/repositories/projections.html) +- [Spring Data JPA — Query Methods and Scrolling](https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html) +- [Spring Data — Query Method Details](https://docs.spring.io/spring-data/data-jpa/reference/4.0/repositories/query-methods-details.html) +- [Spring Boot 4.0 — Data Access](https://docs.spring.io/spring-boot/4.0/how-to/data-access.html) + +### Hibernate ORM + +- [Hibernate ORM 7.1 User Guide](https://docs.hibernate.org/orm/7.1/userguide/html_single/) +- [Hibernate ORM 7.1 Migration Guide](https://docs.jboss.org/hibernate/orm/7.1/migration-guide/migration-guide.html) + +### PostgreSQL + +- [PostgreSQL 16 — Transaction Isolation](https://www.postgresql.org/docs/16/transaction-iso.html) +- [PostgreSQL 16 — Serialization Failure Handling](https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html) +- [PostgreSQL 16 — Explicit Locking](https://www.postgresql.org/docs/16/explicit-locking.html) +- [PostgreSQL 16 — SELECT and `SKIP LOCKED`](https://www.postgresql.org/docs/16/sql-select.html) +- [PostgreSQL 16 — Client Connection Defaults and Timeouts](https://www.postgresql.org/docs/16/runtime-config-client.html) +- [PostgreSQL 16 — Error Codes](https://www.postgresql.org/docs/16/errcodes-appendix.html) +- [PostgreSQL 16 — SET](https://www.postgresql.org/docs/16/sql-set.html) +- [PostgreSQL 16 — Hot Standby](https://www.postgresql.org/docs/16/hot-standby.html) +- [PostgreSQL 16 — High Availability, Load Balancing, and Replication](https://www.postgresql.org/docs/16/high-availability.html) +- [PostgreSQL 16 — EXPLAIN](https://www.postgresql.org/docs/16/sql-explain.html) +- [PostgreSQL 16 — Indexes](https://www.postgresql.org/docs/16/indexes.html) +- [PostgreSQL 16 — Table Partitioning](https://www.postgresql.org/docs/16/ddl-partitioning.html) +- [PostgreSQL 16 — Privileges](https://www.postgresql.org/docs/16/ddl-priv.html) +- [PostgreSQL 16 — ALTER TABLE](https://www.postgresql.org/docs/16/sql-altertable.html) +- [PostgreSQL 16 — CREATE INDEX](https://www.postgresql.org/docs/16/sql-createindex.html) +- [PostgreSQL 16 — Row Security Policies](https://www.postgresql.org/docs/16/ddl-rowsecurity.html) +- [PostgreSQL Versioning Policy](https://www.postgresql.org/support/versioning/) + +### PostgreSQL JDBC + +- [pgJDBC — Using the Driver, Failover and `targetServerType`](https://jdbc.postgresql.org/documentation/use/) +- [pgJDBC — SSL/TLS](https://jdbc.postgresql.org/documentation/ssl/) + +### Pool + +- [HikariCP 7.0.2 — Configuration](https://github.com/brettwooldridge/HikariCP/tree/HikariCP-7.0.2) +- [HikariCP — About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing) + +### Flyway + +- [Flyway — Validate](https://documentation.red-gate.com/flyway/reference/commands/validate) +- [Flyway — Baselines](https://documentation.red-gate.com/flyway/flyway-concepts/baselines) +- [Flyway — Migrations](https://documentation.red-gate.com/fd/migrations-271585107.html) +- [Flyway — Migration Transaction Handling](https://documentation.red-gate.com/fd/migration-transaction-handling-273973399.html) +- [Flyway — `executeInTransaction`](https://documentation.red-gate.com/fd/flyway-execute-in-transaction-setting-277578997.html) +- [Flyway — PostgreSQL Database Support](https://documentation.red-gate.com/flyway/reference/database-driver-reference/postgresql-database) diff --git a/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md b/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md new file mode 100644 index 0000000..55bf8f9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md @@ -0,0 +1,5736 @@ +# Messaging Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: 상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 현재 outbound leaf: `adapter-outbound-messaging` +- 미래 inbound leaf: `adapter-inbound-messaging-kafka` +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) + +## 0. 문서 상태와 구현 상태 + +이 문서는 messaging 전체 수명주기를 한 번에 설계하되 구현은 단계적으로 진행하기 위한 정본이다. +여기서 messaging 전체 수명주기는 다음을 뜻한다. + +```text +domain event + -> integration event + -> transactional outbox + -> polling 또는 CDC dispatch + -> acknowledgement-aware Kafka producer + -> Kafka consumer + -> inbox + application side effect + -> DLT / replay / reconciliation +``` + +이 문서가 검토되었다는 사실은 위 기능이 구현되었거나 production-ready라는 뜻이 아니다. +구현 상태와 향후 추가 가능 범위를 혼동하지 않도록 세 종류의 표현만 사용한다. + +| 표현 | 의미 | +| --- | --- | +| `현재 구현` | 2026-07-28 repository에서 코드와 테스트로 직접 확인한 범위 | +| `최초 R2 구축 대상` | 첫 실행 계획에서 실제로 구현하고 real-service 증거를 만들 범위 | +| `후속 설계 완료 / 미구현` | 경계와 보장은 이 문서에서 결정했지만 코드·설정·증거는 아직 없는 범위 | + +### 0.1 현재 구현 + +현재 repository에는 다음 기반이 있다. + +- `application-core`의 framework-free transactional outbox append/store/publish port; +- 비즈니스 쓰기와 같은 `TransactionPort.inWrite(...)` 안에서 outbox event를 append하는 계약; +- PostgreSQL `SKIP LOCKED` 기반 claim, `PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD` 상태, + retry/backoff, timestamp 기반 aggregate FIFO gate; +- broker publish를 DB transaction 밖에서 수행하고 결과 상태만 짧은 transaction으로 갱신하는 + relay use case; +- `app.messaging.broker`로 단일 `MessageBroker`를 선택하는 outbound composition; +- `KafkaSender`라는 project-supplied seam과 fake 기반 unit test; +- 비활성 시 fail-fast하는 `DisabledMessagePublisher`와 + `DisabledOutboxMessagePublisher`; +- 일반 publisher의 fail-open과 durable outbox publisher의 fail-closed 구분; +- 확인된 FAILED/DEAD 전이 뒤에만 기록되는 typed `OutboxRelayFailureReport`; +- outbox backlog/lag/outcome metric과 stub runbook; +- `sample-portfolio`의 WorkLog/Poster integration-event 예시. + +이 기반이 증명하지 않는 것은 다음과 같다. + +- 실제 Kafka client가 존재한다는 것; +- `KafkaSender.send()` 반환이 broker acknowledgement를 뜻한다는 것; +- 현재 `PUBLISHED` 상태가 실제 broker ACK 뒤에만 기록된다는 것; +- 현재 hand-written JSON envelope가 schema-valid 또는 rolling-compatible하다는 것; +- consumer가 존재하거나 중복을 inbox로 흡수한다는 것; +- TLS/SASL, ACL, topic topology, resource bound, graceful drain이 준비되었다는 것; +- CDC mode나 polling/CDC 전환이 가능하다는 것; +- real Kafka/PostgreSQL/Kafka Connect 장애 시험을 통과했다는 것. + +### 0.2 최초 R2 구축 대상 + +첫 R2 reference tuple은 다음 하나다. + +```text +producer-provider = kafka-spring +producer-semantics = acknowledged-idempotent-v1 +outbox-dispatch = postgresql-polling-v2 +claim-strategy = postgresql-per-record-jit-claim-v1 +wire-format = json-schema-envelope-v1 +topic-management = externally-provisioned-and-validated-v1 +security = sasl-ssl-scram-sha-512-v1 +compression = none-v1 +ordering = per-key-normal-path-sequence-detectable-v1 +transaction-resource = same-postgresql-transaction-resource-v1 +operator-control = authenticated-internal-web-disposition-v1 +consumer = disabled +cdc = disabled +``` + +첫 구현은 다음 순서로 하나의 실제 경로를 만든다. + +1. logical destination과 versioned event contract catalog; +2. UTF-8 JSON envelope v1과 checked-in JSON Schema; +3. immutable `outbox_event`, polling-only `outbox_delivery`, append-only attempt journal; +4. publication epoch, per-record JIT claim, claim token/valid lease, aggregate sequence, + bounded retry/attempt budget; +5. `Spring Kafka`의 `KafkaTemplate`/`ProducerFactory`를 직접 소유하는 outbound provider; +6. broker ACK를 기다리는 typed outcome; +7. TLS/SASL_SSL production profile, finite queue/timeout, readiness와 graceful shutdown; +8. application disposition use case + authenticated internal web operator control; +9. PostgreSQL + real Kafka 통합·장애·보안 evidence. + +첫 R2 구축에는 inbound Kafka consumer, inbox, retry topic, DLT replay, Debezium/Kafka Connect가 +들어가지 않는다. 다만 최초 wire/outbox 구조가 그 후속 기능을 갈아 끼우거나 추가할 수 있도록 +설계한다. + +### 0.3 후속 설계 완료 / 미구현 + +| Capability | 문서상 결정 | 현재 구현 | +| --- | --- | --- | +| inbound Kafka | 별도 `adapter:inbound:messaging-kafka` leaf | 없음 | +| consumer acknowledgement | application commit 뒤 `MANUAL_IMMEDIATE` | 없음 | +| inbox | `(consumerId, eventId)` unique + business write와 같은 DB transaction | 없음 | +| consumer retry | 짧고 bounded한 blocking retry가 기본 | 없음 | +| retry topic | ordering을 잃는 opt-in card | 없음 | +| DLT | DLT publish ACK 뒤 원본 offset 진행 | 없음 | +| replay | 별도 group/job, 범위·승인·audit 필수 | 없음 | +| CDC | 외부 Kafka Connect/Debezium, insert-only event source | 없음 | +| polling/CDC 전환 | 같은 production destination에서 상호 배타, 별도 cutover runbook | 없음 | +| Avro/Protobuf | schema-registry와 함께 optional serialization card | 없음 | +| Kafka transaction/EOS | DB-free Kafka consume-process-produce에만 optional | 없음 | +| 대체 broker | 동일 semantic guarantee/evidence를 만족하는 provider card로만 추가 | 없음 | + +### 0.4 상태 ledger + +이 표는 구현 진척의 human-readable SSOT다. 이후 구현 작업은 이 표만 갱신하고 완료 표현을 +본문 여러 곳에 복제하지 않는다. + +| Phase | 산출물 | 2026-07-28 상태 | 허용 표현 | +| --- | --- | --- | --- | +| P0 | current truth, design, characterization | `IMPLEMENTATION_PLAN_READY` | 설계·실행 계획 완료, 구현 미착수 | +| P1 | event contract/catalog/envelope/schema | `NOT_STARTED` | 미구현 | +| P2 | immutable event + polling delivery v2 | `NOT_STARTED` | legacy polling만 존재 | +| P3 | Spring Kafka ACK-aware producer | `NOT_STARTED` | Kafka seam R0 | +| P4 | security/observability/fault/real-service R2 evidence | `NOT_STARTED` | R2 주장 금지 | +| P5 | inbound Kafka leaf + inbox + DLT/replay | `DESIGNED_NOT_IMPLEMENTED` | 후속 설계 | +| P6 | PostgreSQL Debezium CDC + cutover | `DESIGNED_NOT_IMPLEMENTED` | 후속 설계 | +| P7 | Avro/Protobuf/EOS/대체 provider cards | `OPTIONAL_BACKLOG` | 후보 | + +Phase 진행도와 capability readiness는 별도 축이다. 예를 들어 P3 코드가 존재해도 exact selected +profile이 real Kafka, 보안, fault, shutdown evidence를 통과하지 않으면 R2가 아니다. + +## 1. 설계 판정 + +이번 설계는 provider-neutral semantic contract와 Kafka reference implementation을 분리한다. +provider-neutral이라는 말은 모든 broker의 최저 공통분모를 가진 범용 +`send(topic, key, payload)` API를 만들겠다는 뜻이 아니다. + +선택한 구조는 다음과 같다. + +1. Application은 integration event의 의미, logical destination, identity, ordering intent, + transactional append와 consumption policy를 소유한다. +2. `adapter:outbound:messaging`은 wire contract compilation, JSON envelope, Kafka producer, + ACK outcome, producer lifecycle과 provider telemetry를 소유한다. +3. `adapter:outbound:persistence-jpa`는 same-store outbox/inbox persistence와 claim CAS를 + 소유한다. +4. 첫 reference provider는 outbound leaf 내부의 Spring Kafka + `DefaultKafkaProducerFactory` + `KafkaTemplate`이다. +5. 현재 `KafkaSender`는 legacy/test migration seam으로만 남길 수 있고 R2 provider로 + 표시하지 않는다. +6. durable publication의 성공은 broker ACK metadata가 확인된 경우에만 선언한다. +7. ACK를 기다리다 deadline/cancellation/connection loss가 발생하면 성공이나 확정 실패가 아니라 + `INDETERMINATE`다. +8. 첫 serialization은 versioned UTF-8 JSON envelope와 checked-in JSON Schema다. +9. event type을 physical Kafka topic으로 직접 사용하지 않는다. logical destination을 + deployment binding이 physical topic으로 컴파일한다. +10. polling outbox는 immutable event와 mutable delivery control을 분리한다. +11. CDC는 첫 R2에 포함하지 않고 같은 immutable event source를 사용하는 후속 dispatch card다. +12. consumer를 구현하기 전 inbound Kafka 전용 leaf를 registry에 추가한다. +13. 자동 publication은 bounded하므로 무조건적인 eventual delivery를 주장하지 않는다. + consumer/inbox까지 구현된 범위만 duplicate-possible delivery와 idempotent effect로 표현한다. +14. Kafka producer idempotence나 transaction을 DB와 Kafka 사이의 generic exactly-once로 + 표현하지 않는다. +15. 사용하지 않는 producer/consumer/CDC profile은 connection, thread, scheduler, AdminClient, + connector, schema가 0개여야 한다. + +이 결정의 핵심은 “Kafka로 먼저 하나를 만든다”와 “나중에 교체 가능하게 한다”를 동시에 만족하는 +것이다. 교체 가능성은 빈 SPI 하나가 아니라 stable semantic contract, explicit capability card, +exact profile validation, provider별 evidence로 확보한다. + +## 2. 상위 설계와 기존 심화 설계의 관계 + +상위 Production Capability Platform Design은 다음을 이미 결정했다. + +- transactional outbox append는 source-of-truth transaction과 함께한다; +- dispatch는 `disabled | polling | cdc`로 선택한다; +- immutable `outbox_event`와 polling-only `outbox_delivery`를 분리한다; +- physical topic은 adapter 설정이고 application event type이 아니다; +- real Kafka producer는 ACK, bounded delivery timeout, idempotence, security를 가져야 한다; +- consumer는 별도 inbound leaf, manual acknowledgement, inbox, DLT/replay를 사용한다; +- DB와 broker를 아우르는 generic exactly-once를 주장하지 않는다. + +이번 문서는 그 방향을 구현자가 다시 추론하지 않도록 다음을 추가로 고정한다. + +- 첫 R2 exact tuple; +- stable contract와 replaceable capability card의 경계; +- integration event identity와 versioned wire envelope; +- logical destination catalog와 physical topic binding; +- producer ACK/REJECTED/INDETERMINATE state machine; +- Kafka client retry와 relay retry의 combined amplification budget; +- immutable event/polling delivery schema와 claim token; +- polling ACK-to-DB gap, dead resolution, replay와 retention; +- future consumer/inbox/DLT/replay의 정확한 transaction과 acknowledgement 순서; +- future CDC connector, slot/offset/WAL, shadow/cutover/rollback 조건; +- configuration expected-state, readiness card와 evidence fingerprint; +- 보안, privacy, observability, test/CI/no-skip와 runbook 요구. + +Redis/FileServer/HTTPClient deep design에서 재사용하는 공통 패턴은 다음이다. + +- 현재 truth와 목표를 문서 앞에서 분리한다; +- semantic contract와 provider runtime을 분리한다; +- logical ID와 physical endpoint/topic을 분리한다; +- exact-one provider selection과 disabled resource-0를 사용한다; +- typed outcome으로 definite/indeterminate를 구분한다; +- finite deadline, queue, body/message size와 graceful shutdown을 계약으로 둔다; +- readiness를 capability card와 real-service evidence로 제한한다; +- optional provider/config를 구현되기 전에 존재하는 것처럼 노출하지 않는다. + +그 문서에서 그대로 복사하지 않는 부분은 다음이다. + +- HTTP mutation의 unknown outcome과 Kafka duplicate 가능성은 비슷하지만 동일하지 않다; +- Redis fail-open cache 정책은 durable messaging에 적용하지 않는다; +- Fileserver의 atomic rename/manifest가 Kafka acknowledgement를 대체하지 않는다; +- Kafka partition ordering을 global FIFO나 distributed lock으로 표현하지 않는다; +- Kafka transaction은 DB outbox transaction을 대체하지 않는다. + +세부 내용이 상위 문서의 messaging 요약과 다르면 이 문서가 messaging 범위의 정본이다. +다른 capability 결정은 변경하지 않는다. + +### 2.1 Normative decision ledger + +| 결정 | 정본 절 | +| --- | --- | +| 현재 구현/후속 상태 | §0 | +| 첫 R2 exact tuple | §0.2, §10 | +| guarantee와 readiness 용어 | §7 | +| architecture와 module ownership | §8–§9 | +| capability card와 교체 조건 | §10 | +| identity와 ordering vocabulary | §11 | +| event transformation | §12 | +| contract/destination/topic catalog | §13 | +| JSON envelope와 schema evolution | §14 | +| application outcome contract | §15 | +| ACK-aware Kafka producer | §16–§17 | +| polling outbox state/data | §18–§19 | +| best-effort 경계 | §20 | +| activation/configuration | §21 | +| security/topic governance | §22 | +| observability/readiness | §23 | +| consumer/inbox/DLT/replay | §24–§25 | +| CDC와 mode cutover | §26–§27 | +| test/CI/evidence | §28–§29 | +| migration/status update | §30 | +| 완료/후속 card | §31 | +| runbook | §32 | + +예시 YAML, Java pseudocode, migration alias, README, runbook은 이 ledger의 정본 절보다 우선하지 +않는다. 두 activation source가 충돌하면 임의 precedence나 fallback을 적용하지 않고 startup을 +실패시킨다. + +## 3. 증거 기반 현재 상태 + +### 3.1 실제 Kafka production dependency가 없다 + +`adapter:outbound:messaging/build.gradle`의 production dependency는 현재 다음뿐이다. + +```text +application-core +shared-contract +adapter:outbound:support +spring-boot-autoconfigure +slf4j-api +``` + +`spring-kafka`와 `kafka-clients`가 없으므로 production `KafkaProducer`, +`ProducerFactory`, `KafkaTemplate`, `AdminClient`도 없다. `app-bootstrap`의 +`testCompileOnly kafka-clients`는 architecture test classpath를 위한 것이며 실제 provider가 +아니다. + +따라서 현재 `app.messaging.broker=kafka`는 Kafka capability 활성화가 아니라 fork project가 +`KafkaSender` bean을 별도로 제공했을 때 seam을 선택한다는 의미다. + +### 3.2 `void KafkaSender.send()`는 broker ACK를 표현하지 못한다 + +현재 contract는 다음과 같다. + +```java +void send(OutboundMessage message) throws Exception; +``` + +Kafka `send()`는 일반적으로 local buffer에 record를 넣고 future를 즉시 반환한다. 외부 seam이 +future를 기다리는지, 어떤 `acks`를 쓰는지, delivery timeout이 유한한지 repository는 알 수 없다. +그런데 application의 `OutboxEventStatus.PUBLISHED` 문서는 broker acknowledgement를 뜻한다고 +설명한다. + +현재 seam 구현자가 callback/future 완료 전에 정상 반환하면 relay는 ACK가 없는 record를 +`PUBLISHED`로 기록한다. 이는 단순한 구현 누락이 아니라 현재 상태 이름과 실제 evidence의 +불일치다. + +### 3.3 event type이 physical topic으로 사용된다 + +현재 `OutboxMessagePublishAdapter`는 다음 매핑을 한다. + +```text +topic = event.eventType() +key = event.aggregateId() +payload = hand-written envelope +``` + +이 구조는 application event naming이 Kafka topic naming, ACL, retention, partition count, +replication, environment naming과 결합되게 한다. event type을 동적으로 만들 수 있으면 arbitrary +topic publication과 metric cardinality도 열린다. + +목표에서는 `contractId -> logicalDestination -> physicalTopicBinding`의 닫힌 두 단계 mapping을 +사용한다. + +### 3.4 envelope가 schema-bound document가 아니다 + +`OutboxEnvelopeJson`은 문자열을 직접 이어 붙인다. payload는 “이미 올바른 JSON”이라는 주석 +계약만 있고 parser/schema validation 없이 verbatim 삽입된다. + +현재 envelope에는 다음도 없다. + +- envelope spec version; +- payload schema version; +- contract ID; +- aggregate type와 aggregate sequence; +- causation ID; +- content type; +- logical destination; +- schema hash/compatibility evidence; +- maximum depth/string/array/record bytes; +- rolling producer/consumer compatibility fixture. + +따라서 현재 JSON은 example wire shape이지 versioned integration contract가 아니다. + +### 3.5 immutable event와 mutable delivery state가 한 행에 섞여 있다 + +현재 `outbox_event`에는 event metadata와 다음 polling state가 함께 있다. + +```text +status +attempt_count +next_attempt_at +``` + +relay는 같은 row를 `PENDING -> IN_FLIGHT -> PUBLISHED/FAILED/DEAD`로 UPDATE한다. +Debezium Outbox Event Router는 outbox table을 INSERT-only queue로 기대하고 UPDATE를 비정상 +operation으로 분류한다. 현재 table에 connector flag만 켜는 방식으로 CDC를 추가할 수 없다. + +### 3.6 현재 FIFO는 동일 timestamp와 긴 batch에서 불완전하다 + +현재 claim과 defensive sort는 `occurred_at` 중심이다. 같은 aggregate에서 같은 timestamp를 가진 +두 event의 완전한 tie-breaker나 domain aggregate sequence가 없다. 따라서 strict ordering을 +증명할 수 없다. + +또한 batch row는 같은 시점에 `now + inFlightTimeout`으로 claim되고 순차 publish된다. batch의 +최악 처리 시간이 in-flight timeout을 넘으면 뒤쪽 row를 첫 worker가 처리 중일 때 다른 worker가 +재claim할 수 있다. + +목표에서는 aggregate sequence, claim token, per-row remaining-lease validation, attempt budget과 +claim lease의 관계를 고정한다. + +### 3.7 polling ACK-to-DB gap은 이미 duplicate를 허용한다 + +현재 순서는 다음과 같다. + +```text +publishPort.publish(event) + -> 별도 DB transaction에서 markPublished(eventId) +``` + +broker가 record를 수락한 뒤 process가 종료되거나 `markPublished`가 실패하면 row는 +`IN_FLIGHT`에 남고 timeout 뒤 재claim된다. 이는 올바른 transactional outbox에서 피할 수 없는 +ACK-to-state duplicate gap이며 consumer dedupe가 필요하다. + +현재 repository에는 inbound consumer와 inbox가 없으므로 “consumer dedupe가 안전하게 흡수한다”는 +runbook 표현은 목표 계약이지 현재 보장이 아니다. + +### 3.8 retry 분류가 모든 exception을 같은 경로로 보낸다 + +현재 publish exception은 attempt count가 남았으면 FAILED, 소진됐으면 DEAD가 된다. 다음을 +구분하지 않는다. + +- local schema/size violation처럼 절대 broker에 도달하지 않은 permanent rejection; +- authorization/topic-not-found처럼 configuration/operator 조치가 필요한 failure; +- retriable broker/network failure; +- broker가 수락했을 수도 있는 timeout/cancel/connection loss; +- programming defect; +- stale claim owner가 수행한 결과. + +poison event도 max attempt까지 재시도하므로 불필요한 amplification과 aggregate head blocking을 +만든다. + +### 3.9 configuration은 topology와 guarantee를 표현하지 못한다 + +현재 typed settings는 사실상 다음뿐이다. + +```text +app.messaging.broker +app.messaging.kafka.brokers +``` + +다음이 없다. + +- expected state; +- producer semantic profile; +- logical destination binding; +- contract/schema catalog; +- dispatch mode; +- acknowledgement deadline; +- delivery/request/max-block timeout; +- buffer/in-flight/batch/record size; +- security protocol, TLS, SASL, secret reference; +- topic partitions/replication/min ISR/retention expectation; +- readiness requirement; +- shutdown drain; +- selected capability/evidence digest. + +host:port regex도 IPv6, duplicate endpoint, port range, blank-normalization과 secret/source policy를 +충분히 검증하지 않는다. + +### 3.10 best-effort와 durable success vocabulary가 섞일 수 있다 + +일반 `OutboundMessagePublisher`는 exception을 삼키는 fail-open이고 outbox publisher는 +exception을 전파하는 fail-closed다. 이 구분 자체는 유용하다. + +그러나 둘 다 같은 `MessageBroker.send(void)`를 호출하므로 다음을 구분하지 못한다. + +- local admission; +- producer buffer enqueue; +- broker acknowledgement; +- definite rejection; +- indeterminate result. + +일반 publisher의 `logSuccess`도 broker ACK가 아니라 seam의 정상 반환만 의미할 수 있다. + +### 3.11 consumer/inbox/CDC runtime이 없다 + +현재 registry에는 19개 leaf만 있고 inbound Kafka leaf가 없다. production source 검색 기준으로 +다음도 없다. + +- `@KafkaListener` 또는 listener container; +- consumer group/offset/ack policy; +- deserializer allowlist; +- inbox table/port/executor; +- retry/DLT/replay; +- rebalance/pause/resume/drain; +- Debezium connector/Kafka Connect deployment; +- replication slot/offset/WAL monitoring. + +이 기능은 package를 outbound leaf에 추가하지 않고 각각 §24–§27의 단계에서 도입한다. + +### 3.12 현재 test와 runbook이 증명하는 범위 + +현재 focused messaging test는 fake sender/broker와 settings/composition/report rendering을 +검증한다. PostgreSQL outbox integration test는 same-transaction append, row lifecycle, +normal-path two-worker `SKIP LOCKED` row partitioning과 일부 claim 동작을 검증한다. + +현재 test가 증명하지 않는 것은 다음이다. + +- real broker ACK metadata; +- leader loss/min ISR/timeout/duplicate; +- real buffer saturation; +- TLS/SASL/ACL; +- topic drift; +- producer close/drain; +- schema compatibility; +- consumer rebalance/inbox; +- CDC restart/slot/offset. + +`outbox-publish-failed` runbook은 이미 제거된 `APP_MESSAGING_KAFKA_ENABLED`와 존재하지 않는 +`KafkaOutboxMessagePublishAdapter`를 참조한다. `outbox-dead-letter` runbook은 raw SQL로 상태를 +직접 수정하며 operator identity, reason, CAS, audit generation이 없다. 두 문서는 R2 구현과 함께 +도구 기반 절차로 교체해야 한다. + +## 4. 범위와 명시적 비범위 + +### 4.1 전체 설계 범위 + +이 문서는 다음을 설계한다. + +- domain event와 integration event의 분리; +- framework-free event metadata와 application outbox contract; +- logical destination, contract, payload schema, topic binding catalog; +- JSON envelope v1, schema evolution와 compatibility evidence; +- ACK-aware Kafka producer와 typed certainty; +- producer retry, ordering, batching, compression, resource bound와 lifecycle; +- immutable event + polling delivery state; +- claim token, retry/dead/replay/retention; +- best-effort와 durable publication 구분; +- typed activation, expected state와 capability cards; +- TLS/SASL, ACL, topic governance, secret rotation; +- metrics/tracing/log/readiness; +- future inbound Kafka leaf; +- consumer manual ack, inbox, retry, DLT, replay; +- future PostgreSQL Debezium CDC; +- polling/CDC shadow, cutover, rollback; +- real-service/fault/security/compatibility CI. + +### 4.2 최초 R2 baseline + +최초 R2는 다음 common subset만 구현한다. + +- Kafka 한 provider; +- polling outbox 한 dispatch mode; +- JSON Schema 한 serialization profile; +- external topic provisioning + startup validation; +- acknowledged idempotent producer; +- production TLS/SASL_SSL와 explicit local plaintext profile; +- single-cluster, single-region producer; +- bounded record, buffer, batch, retry, deadline와 shutdown; +- real PostgreSQL + Kafka qualification. + +consumer와 CDC는 설계에 포함되지만 최초 R2 implementation acceptance에는 포함되지 않는다. + +### 4.3 후속 optional capability + +다음은 stable boundary 뒤에 추가할 수 있다. + +- Kafka manual-ack consumer + PostgreSQL inbox; +- retry-topic/delayed retry; +- Debezium PostgreSQL CDC; +- Avro + schema registry; +- Protobuf + schema registry; +- JSON Schema registry; +- Kafka transactional consume-process-produce; +- Kafka Streams; +- alternative partitioner proven by ordering vectors; +- multi-cluster replication/failover; +- broker/provider 대체; +- contract-specific compaction; +- large-message claim-check pattern with object storage; +- module split 또는 external capability artifact. + +optional card는 이름만 등록하지 않는다. code, typed settings, tests, runbook, evidence가 같은 +변경에서 존재할 때 registry에 추가한다. + +### 4.4 비범위 + +다음은 이번 설계의 목표가 아니다. + +- arbitrary topic/key/header를 application에 노출하는 Kafka facade; +- 모든 broker를 lowest-common-denominator API로 감싸기; +- DB와 Kafka를 XA/distributed transaction으로 묶기; +- generic exactly-once delivery 주장; +- Kafka partition ownership을 distributed lock으로 사용하기; +- Kafka를 source-of-truth database로 일반화하기; +- unbounded event sourcing platform; +- dynamic user input으로 topic 생성하기; +- payload에 임의 Java class/type header를 넣기; +- active-active multi-region ordering을 보장하기; +- 대용량 binary를 Kafka record에 직접 싣기; +- sample-portfolio business contract를 production leaf에 넣기. + +## 5. HARD invariants + +다음 중 하나라도 위반하면 동작하는 코드라도 messaging 설계 구현 완료가 아니다. + +1. `domain-core`는 Kafka, Spring, JSON library, database, transport 타입을 알지 않는다. +2. `application-core`는 `KafkaTemplate`, `ProducerRecord`, `ConsumerRecord`, offset, + partition SDK 타입을 알지 않는다. +3. business/best-effort/outbox publication producer와 inbound consumer는 같은 leaf에 두지 + 않는다. inbound processing lifecycle에만 쓰이는 closed retry/DLT publisher는 + `adapter:inbound:messaging-kafka`가 소유할 수 있는 유일한 명시적 예외이며 application publish, + outbox relay 또는 arbitrary destination 전송에 재사용하지 않는다. +4. inbound Kafka leaf는 persistence 또는 outbound messaging leaf에 직접 의존하지 않는다. +5. business write와 durable event append는 같은 source-of-truth transaction에 참여한다. +6. event type을 physical topic으로 암묵 변환하지 않는다. +7. application은 arbitrary topic/header/security property를 전달하지 않는다. +8. 모든 publish는 closed contract catalog와 destination binding을 통과한다. +9. wire envelope와 payload는 append 전에 versioned schema와 size limit을 통과한다. +10. broker ACK metadata 전에는 durable publish 성공을 선언하지 않는다. +11. future 반환/local enqueue를 broker ACK라고 부르지 않는다. +12. timeout/cancel/late callback race를 definite failure로 축소하지 않는다. +13. `INDETERMINATE`는 broker acceptance 가능성을 보존한다. +14. producer idempotence를 process restart나 relay retry 전체의 dedupe로 표현하지 않는다. +15. Kafka transaction을 DB + Kafka atomic commit으로 표현하지 않는다. +16. end-to-end는 bounded source/retention 조건 안의 duplicate-possible delivery와 + inbox-covered idempotent effect로만 표현한다. +17. `outbox_event`는 CDC qualification 전에 insert-only immutable source가 된다. +18. polling mutable state는 `outbox_delivery`에만 둔다. +19. polling과 CDC는 같은 production destination에서 동시에 발행하지 않는다. +20. shadow CDC는 격리된 topic과 격리된 consumer group만 사용한다. +21. strict aggregate ordering은 timestamp가 아니라 explicit aggregate sequence를 요구한다. +22. active worker가 소유한 renew/outcome transition은 current claim token/owner와 unexpired + DB-time lease를 CAS 조건으로 검증한다. initial claim, expired reclaim, operator disposition과 + generation authority handoff는 §18.6의 각기 다른 fenced predicate를 사용한다. +23. Kafka client retry와 relay retry는 하나의 finite amplification budget으로 검증한다. +24. timeout, queue, buffer, in-flight, batch, payload, header, retry는 모두 finite다. +25. automatic provider fallback과 automatic topic creation은 production에서 금지한다. +26. disabled profile은 client, AdminClient, listener, scheduler, connector, refresh thread가 0개다. +27. production plaintext와 literal secret은 startup fail-closed다. +28. event payload, partition key, tenant, credential, raw headers는 log/metric tag에 넣지 않는다. +29. consumer offset은 application transaction commit 뒤에만 진행한다. +30. `APPLIED`와 verified `DUPLICATE`만 바로 ACK할 수 있다. +31. poison record를 DLT로 보낼 때 DLT publish ACK 전에는 원본 offset을 진행하지 않는다. +32. retry topic이 ordering을 잃는다는 사실을 숨기지 않는다. +33. replay는 별도 audited operation이며 운영 group offset을 임의 rewind하지 않는다. +34. R2는 exact selected profile의 real-service/security/fault/shutdown evidence가 있어야 한다. +35. required qualification lane은 Docker나 broker 부재를 이유로 silent skip하지 않는다. +36. 현재 구현되지 않은 consumer/CDC/schema-registry setting을 live config처럼 추가하지 않는다. +37. production leaf는 `sample-portfolio` contract나 fixture에 의존하지 않는다. +38. 모든 dependency edge는 `src/config/architecture/modules.json` 변경과 검증을 통과한다. +39. nullable tenant scope를 unique/dedupe/order key에 사용하지 않는다. +40. legacy/v2/polling/CDC relay authority는 같은 destination에서 항상 정확히 하나다. +41. CDC commit order를 aggregate sequence order라고 표현하지 않는다. +42. plain unique-violation catch 뒤 rollback-only PostgreSQL/JPA transaction을 계속 사용하지 않는다. +43. consumer의 bounded retry가 소진되면 durable HOLD와 partition/container stop 중 하나로 + automation을 끝낸다. recoverer 실패로 같은 retry cycle을 무기한 다시 시작하지 않는다. + +## 6. 대안 검토 + +### 6.1 현재 `KafkaSender` seam만 확장 + +장점: + +- broker SDK가 leaf에 없으므로 가볍다; +- fake unit test가 쉽다; +- fork project가 client를 자유롭게 선택할 수 있다. + +문제: + +- actual producer config와 lifecycle evidence를 template이 소유하지 못한다; +- ACK, timeout, buffer saturation, TLS/SASL, metrics를 검증할 수 없다; +- fork마다 guarantee가 달라져 같은 capability label을 사용할 수 없다. + +판정: legacy/test seam으로만 유지한다. `void/throws` shape는 R2 선택 불가다. + +### 6.2 Apache Kafka client 직접 사용 + +장점: + +- `KafkaProducer` lifecycle, callback, metrics, transaction을 가장 직접 통제한다; +- Spring abstraction 없이 Kafka API 의미를 그대로 사용할 수 있다. + +문제: + +- producer factory, generation rotation, close/drain, observation, transaction cache, + Spring lifecycle integration을 모두 직접 소유해야 한다; +- 현재 Spring Boot composition과 중복이 커진다. + +판정: future provider candidate다. Spring Kafka가 보장을 막는 구체적 evidence가 있을 때만 +추가한다. + +### 6.3 Spring Kafka `KafkaTemplate` + explicit producer factory + +장점: + +- 실제 Kafka producer guarantee를 사용하면서 Spring lifecycle/composition과 정렬된다; +- future/ACK metadata, observation, test support를 사용할 수 있다; +- provider config를 outbound leaf가 직접 검증할 수 있다. + +주의: + +- Boot의 broad auto-configuration/default에 activation을 맡기지 않는다; +- `KafkaTemplate.send()` 반환 자체가 ACK가 아니므로 future 완료를 기다려야 한다; +- shared producer에서 per-message `flush()`를 사용하지 않는다; +- provider settings를 Kafka raw map으로 무제한 노출하지 않는다. + +판정: 첫 reference provider로 선택한다. + +### 6.4 Spring Cloud Stream/binder를 baseline으로 사용 + +장점: + +- binder 교체와 functional pipeline이 편리하다; +- broker-neutral developer experience를 제공할 수 있다. + +문제: + +- 현재 요구는 exact producer acknowledgement, outbox state, topic/security drift, + client retry와 lifecycle을 직접 증명하는 것이다; +- binder abstraction이 provider-specific guarantee와 evidence ownership을 흐릴 수 있다; +- dependency와 activation 범위가 첫 reference path보다 크다. + +판정: 첫 baseline으로 선택하지 않는다. 동일 semantic card와 exact evidence를 만족하는 +future provider로 검토할 수 있다. + +### 6.5 consumer를 outbound messaging leaf에 추가 + +장점: + +- Kafka dependency와 설정을 한 곳에서 공유한다. + +문제: + +- producer는 driven adapter이고 consumer는 driving adapter다; +- outbound leaf가 use case invocation, offset lifecycle, rebalance를 소유하게 된다; +- adapter-to-adapter/persistence 직접 의존 유혹이 생긴다. + +판정: 금지한다. consumer 구현 전 별도 inbound leaf를 추가한다. + +### 6.6 CDC를 첫 dispatch로 구현 + +장점: + +- Java polling scheduler와 ACK-to-mark gap을 제거한다; +- database log 기반 확장성이 좋을 수 있다. + +문제: + +- 현재 mutable table과 호환되지 않는다; +- Kafka Connect, Debezium, replication slot, WAL, offset topic, snapshot과 운영 범위가 함께 + 필요하다; +- 첫 실제 producer/contract도 없는 상태에서 장애 면적이 너무 크다. + +판정: 전체 설계에는 포함하되 첫 구현은 polling이다. + +### 6.7 polling만 설계하고 CDC는 나중에 처음부터 다시 설계 + +문제: + +- mutable row/envelope가 굳으면 CDC 전환 비용이 커진다; +- topic/identity/wire parity가 dispatch 구현마다 갈라진다. + +판정: 거부한다. 처음부터 immutable event와 replaceable dispatch card를 둔다. + +### 6.8 JSON Schema, Avro, Protobuf + +JSON Schema 장점: + +- 현재 JSON 예제와 이행 거리가 짧다; +- schema 파일과 golden vectors를 repository에서 바로 review할 수 있다; +- registry 없이 첫 contract를 세울 수 있다. + +JSON Schema 한계: + +- compatibility를 schema diff heuristic만으로 완전히 증명할 수 없다; +- binary 효율과 generated type safety는 Avro/Protobuf보다 약할 수 있다. + +Avro/Protobuf 장점: + +- generated type과 schema registry ecosystem이 강하다; +- compact binary wire를 제공한다. + +Avro/Protobuf 비용: + +- registry availability/security/compatibility mode와 build generation을 함께 설계해야 한다; +- 현재 skeleton의 첫 R2 경로를 넓힌다. + +판정: JSON Schema v1을 먼저 구축하고 Avro/Protobuf는 별도 evidence card로 추가한다. + +### 6.9 Kafka transaction을 모든 durable publish에 사용 + +Kafka transaction은 Kafka 안의 여러 record와 offset을 원자화할 수 있다. 그러나 PostgreSQL +business commit과 Kafka transaction을 하나의 atomic commit으로 만들지 않는다. Spring의 DB/Kafka +transaction synchronization도 순차 commit이며 두 번째 commit 실패 가능성이 남는다. + +판정: polling outbox producer baseline에서는 사용하지 않는다. DB-free +Kafka consume-process-produce에만 optional card로 둔다. + +## 7. Capability readiness와 guarantee vocabulary + +### 7.1 Readiness level + +| Level | 의미 | +| --- | --- | +| R0 | interface/seam/example만 존재; 실제 service guarantee 없음 | +| R1 | deterministic unit/contract/local composition은 검증; production topology/fault 증거 없음 | +| R2 | exact selected profile이 real service, security, fault, lifecycle, compatibility gate 통과 | +| R3 | HA/failover/upgrade/DR/capacity와 운영 rehearsal까지 통과 | + +`KafkaSender`는 R0다. 현재 PostgreSQL polling control plane은 일부 real-DB test가 있으므로 R1 +skeleton으로 설명할 수 있지만 end-to-end Kafka publication은 R0다. + +### 7.2 Publication vocabulary + +| 용어 | 정확한 의미 | +| --- | --- | +| `COMPILED` | contract + destination + provider profile이 startup에 검증됨 | +| `ADMITTED` | local bounded admission을 통과함 | +| `ENQUEUED` | producer local buffer가 record를 받음 | +| `ACKNOWLEDGED` | configured ACK 조건을 만족한 broker metadata를 local process가 관찰함 | +| `ACKNOWLEDGED_MISMATCH` | ACK metadata가 compiled destination과 달라 misroute incident가 됨 | +| `REJECTED` | provider가 record 비수락을 확정할 수 있음 | +| `INDETERMINATE` | broker가 수락했을 수도, 아닐 수도 있음 | +| `DELIVERY_RECORDED` | ACK 뒤 polling delivery row가 terminal success로 commit됨 | +| `CONSUMED` | consumer가 record를 읽음; business side effect 완료와 다름 | +| `APPLIED` | inbox + business effect transaction이 commit됨 | +| `OFFSET_COMMITTED` | APPLIED/DUPLICATE 뒤 Kafka offset이 진행됨 | + +`ACKNOWLEDGED`와 `DELIVERY_RECORDED` 사이 crash는 duplicate를 만든다. +`APPLIED`와 `OFFSET_COMMITTED` 사이 crash도 duplicate delivery를 만든다. 둘 다 event ID와 inbox가 +흡수해야 하는 duplicate-possible 경계다. `ACKNOWLEDGED_MISMATCH`는 retry 가능한 일반 실패가 +아니며 producer admission과 relay scope를 멈추는 fatal misroute incident다. + +### 7.3 허용 guarantee + +최초 R2가 주장할 수 있는 표현: + +```text +same-store transactional outbox append ++ bounded automatic publish attempts ++ broker ACK 또는 명시적 unresolved/operator disposition ++ 같은 producer generation의 정상 경로에서 stable key별 Kafka order ++ aggregate sequence를 통한 gap/regression 탐지 가능성 ++ idempotent Kafka producer within its supported producer session ++ explicit duplicate handling requirement +``` + +이는 무조건적인 eventual delivery 또는 failure-path strict FIFO가 아니다. finite budget이 끝난 +`EXHAUSTED`, 승인된 `SKIPPED/COMPENSATED`, 영구 hold가 존재할 수 있다. 따라서 첫 R2 card의 +정확한 표현은 `durable acknowledged-or-explicit-disposition publication`이다. + +future consumer/inbox까지 구현한 뒤 주장할 수 있는 표현: + +```text +declared source/retention/disposition horizon 안의 duplicate-possible delivery ++ idempotent application effect for inbox-covered handlers +``` + +허용하지 않는 표현: + +- exactly-once DB-to-Kafka; +- exactly-once end-to-end; +- global ordering; +- no duplicates; +- no loss across an unqualified CDC slot failover; +- DLT가 곧 성공 처리 또는 데이터 복구라는 표현. + +### 7.4 Evidence identity + +R2 evidence는 단순 test 이름이 아니라 다음 fingerprint에 묶인다. + +```text +semantic-card-version +provider-card-version +Kafka client version +Spring Kafka version +broker image/version +JDK version +security profile +topic profile +contract catalog hash +schema set hash +settings digest +test scenario version +``` + +다른 version/profile에 이전 evidence를 자동 승계하지 않는다. + +## 8. 목표 아키텍처 + +```mermaid +flowchart LR + DOMAIN[Domain event] --> MAP[Application integration-event mapping] + MAP --> APPEND[OutboxAppendPort] + APPEND --> DBTX[(Business DB transaction)] + DBTX --> EVENT[(immutable outbox_event)] + DBTX --> DELIVERY[(polling outbox_delivery)] + + DELIVERY --> RELAY[Application polling relay] + RELAY --> PUBPORT[OutboxMessagePublishPort] + PUBPORT --> CATALOG[Contract + destination compiler] + CATALOG --> KAFKA[Spring Kafka ACK-aware provider] + KAFKA --> TOPIC[(Kafka topic)] + + EVENT -. future CDC .-> DBZ[Debezium / Kafka Connect] + DBZ -. same wire contract .-> TOPIC + + TOPIC -. future consume .-> INBOUND[adapter:inbound:messaging-kafka] + INBOUND --> CUSE[Application consume use case] + CUSE --> INBOXTX[(Inbox + business + optional outbox transaction)] + INBOXTX --> ACK[Kafka offset ACK] + + BOOT[app-bootstrap] -. compile exact profiles .-> CATALOG + BOOT -. compose .-> RELAY + BOOT -. future compose .-> INBOUND + OBS[Metrics / trace / readiness] -. bounded observation .-> KAFKA + OBS -. bounded observation .-> INBOUND +``` + +### 8.1 Stable plane + +provider/dispatch가 바뀌어도 다음은 유지한다. + +- event ID와 contract ID; +- schema version과 envelope version; +- logical destination; +- aggregate identity/sequence와 partition-key intent; +- publication certainty taxonomy; +- inbox dedupe identity; +- application transaction meaning; +- capability/evidence descriptor shape. + +### 8.2 Replaceable plane + +다음은 capability card로 교체할 수 있다. + +- Kafka Spring provider / direct Kafka provider / future broker provider; +- polling / CDC; +- JSON Schema / Avro / Protobuf; +- blocking retry / retry topic; +- PostgreSQL inbox / other same-store inbox; +- local plaintext dev / TLS / SASL_SSL security; +- externally provisioned topic validation / future managed provisioning; +- non-transactional idempotent producer / Kafka transactional workflow. + +### 8.3 교체가 아닌 것 + +다음은 silent fallback이며 금지한다. + +- Kafka outage 때 다른 broker로 자동 전송; +- schema validation 실패 때 raw JSON으로 전송; +- CDC 장애 때 polling을 자동으로 동시에 켬; +- TLS secret 실패 때 plaintext로 연결; +- DLT publish 실패 때 원본 offset을 ACK; +- required consumer failure 때 record를 best-effort로 폐기. + +## 9. 모듈과 계층 소유권 + +### 9.1 `domain-core` + +소유: + +- 순수 domain event와 aggregate invariant; +- aggregate version/sequence가 domain 의미일 때 그 증가 규칙. + +금지: + +- integration topic/destination; +- JSON/schema; +- outbox/inbox; +- Kafka header/partition; +- retry/DLT. + +Domain event는 같은 bounded context 내부의 사실이다. Integration event는 외부 consumer와의 +versioned contract이므로 자동으로 동일한 타입을 직렬화하지 않는다. + +### 9.2 `application-core` + +소유: + +- integration-event draft의 framework-free metadata; +- `OutboxAppendPort`; +- polling relay orchestration; +- provider-neutral publication outcome/certainty; +- provider-neutral late-publication observation drain과 attempt observation port; +- producer generation 교체 시 durable `INDETERMINATE/HOLD`, admission 재개와 generation barrier를 + 조정하는 provider-neutral rotation use case; +- outbox requeue/hold/skip/compensate command use case와 authorization/audit policy; +- future `InboxStorePort`와 `MessageConsumptionExecutor`; +- feature consume use case와 transaction policy; +- retry/dead decision의 application/operational policy. + +금지: + +- physical topic; +- `KafkaTemplate`, record metadata SDK 타입; +- connector/replication slot; +- JPA entity; +- Micrometer/SLF4J. + +### 9.3 `adapter:outbound:persistence-jpa` + +소유: + +- `outbox_event`, `outbox_delivery`, future `inbox_consumption` migration/entity/repository; +- PostgreSQL claim query와 claim-token + unexpired DB-time lease CAS; +- same-store transaction participation; +- attempt observation append와 audited disposition/authority-handoff persistence; +- polling retention query와 operator transition persistence; +- future inbox unique constraint. + +금지: + +- Kafka producer; +- topic routing; +- event business mapping; +- use-case retry policy. + +### 9.4 `adapter:outbound:messaging` + +소유: + +- destination binding compiler; +- envelope/payload schema validation runtime; +- provider-private publish gateway; +- Spring Kafka producer factory/template/AdminClient; +- ACK mapping, finite deadline, buffer/admission; +- late completion을 payload-free bounded queue로 노출하는 application port 구현; +- producer security와 provider-private generation 생성/drain/close/attestation primitive 및 + payload-free lifecycle fact; +- adapter-local best-effort publisher; +- structured outbox diagnostics. + +금지: + +- consumer listener; +- inbox repository; +- JPA entity; +- business route/event policy; +- sample contract. + +초기 package shape: + +```text +dev.caskeleton.adapter.outbound.messaging + config/ + contract/ + destination/ + envelope/ + publication/ + kafka/ + lifecycle/ + observation/ + outbox/ +``` + +package 이름은 예시이고 책임 분리가 정본이다. + +### 9.5 미래 `adapter:inbound:messaging-kafka` + +consumer 구현 전 registry migration으로 새 leaf를 추가한다. + +```text +module id: adapter-inbound-messaging-kafka +gradle path: :adapter:inbound:messaging-kafka +source path: src/adapter/inbound/messaging-kafka +allowed production dependencies: + - application-core + - domain-core + - shared-contract +``` + +정확한 allowed edge는 그 시점의 `modules.json` review로 확정한다. persistence/outbound messaging +adapter edge는 추가하지 않는다. `app-bootstrap`만 inbound listener, application use case, +transaction/inbox provider를 조립한다. + +소유: + +- Kafka listener container; +- record/header/envelope decode와 application command mapping; +- ack/seek/pause/resume/rebalance; +- consumer-local retry/DLT publisher. §5의 HARD invariant 3에 둔 유일한 예외이며 closed + retry/DLT binding 외 + destination과 application/outbox publication에는 사용할 수 없음; +- consumer lifecycle/metrics/security. + +금지: + +- repository 직접 호출; +- JPA entity; +- producer outbox implementation; +- business effect. + +### 9.6 `shared-contract` + +소유 가능: + +- skeleton-wide generic envelope JSON Schema; +- framework-free bounded operational descriptor vocabulary; +- error/metric contract에서 truly shared인 값. + +금지: + +- WorkLog/Poster 등 business event schema; +- Kafka SDK; +- provider setting; +- feature-specific topic. + +현재 `shared-contract/CLAUDE.md`에는 messaging schema resource가 아직 책임으로 등록되어 있지 +않다. P1에서 공통 envelope schema를 추가하는 변경은 같은 commit scope에서 해당 +`CLAUDE.md`의 Responsibility를 갱신하고 Java-stdlib-only 규칙과 business-free 검증을 +추가해야 한다. 이 정책 갱신 없이 resource만 넣지 않는다. + +### 9.7 `app-bootstrap` + +소유: + +- leaf가 bind/validate한 typed settings의 cross-leaf aggregation; +- exact capability tuple selection; +- cross-field/expected-state validation; +- provider, relay, future listener와 health composition; +- required capability readiness aggregation; +- secret reference resolution. + +금지: + +- event mapping; +- retry/DLT business policy; +- repository/Kafka implementation; +- schema compatibility rule 자체. + +broker namespace의 typed settings/value validation은 현재 local SSOT와 같이 +`adapter:outbound:messaging`이 소유한다. `app-bootstrap`은 raw Kafka map을 다시 bind하지 않고 +leaf의 compiled descriptor를 transaction resource, persistence dispatch와 합성한다. + +### 9.8 `sample-portfolio` + +소유: + +- sample domain event -> sample integration event mapping; +- sample payload schema/golden vectors; +- sample contract catalog contribution; +- sample consumer fixture가 생길 경우 application consume use case. + +Production leaf는 sample module에 의존하지 않는다. 새 프로젝트는 sample contract를 제거하고 자기 +feature contract를 같은 확장점에 등록한다. + +합법적인 조립 경로는 다음으로 고정한다. + +1. framework-free `IntegrationEventContractContribution` SPI는 `application-core`에 둔다; +2. feature/sample module은 typed payload record, schema resource와 contribution bean을 제공한다; +3. outbound messaging compiler는 application SPI의 bean 목록만 주입받으며 sample class를 import, + scan 또는 `Class.forName`하지 않는다; +4. production `app-bootstrap`은 sample에 의존하지 않는다. base skeleton은 messaging + `DISABLED`이고 empty catalog가 정상이다; +5. provider qualification test는 test-source fixture contribution을 사용한다; +6. standalone sample이 ACTIVE example을 실행하는 phase에서만 + `sample-portfolio -> adapter-outbound-messaging` runtime edge를 `modules.json`과 + `sample-portfolio/build.gradle`에 함께 추가한다. 이 edge는 fixture consumer 방향이며 반대 + edge는 금지한다. + +Spring bean discovery는 조립 수단일 뿐 contract SSOT가 아니다. 동일 contribution 목록으로 +build-time checksum manifest와 runtime compiler를 검증한다. + +application SPI의 최소 shape는 다음처럼 closed type token을 포함한다. + +```java +interface IntegrationPayload {} + +interface IntegrationEventContractContribution

{ + ContractId contractId(); + int payloadVersion(); + Class

exactPayloadRecordType(); + List canonicalRecordComponentOrder(); + SchemaResourceId payloadSchemaResource(); + Sha256 payloadSchemaHash(); + ContractDescriptor descriptor(); +} +``` + +이는 API 이름을 고정하는 코드가 아니라 경계를 고정하는 pseudocode다. outbound compiler는 +startup에 `exactPayloadRecordType()`이 final Java record이고 descriptor가 허용한 scalar, +collection, nested-record component만 갖는지 검증한다. runtime payload는 exact class equality로 +closed catalog를 찾으며 assignable-type scan, `Class.forName`, default typing, feature-provided +Jackson serializer를 허용하지 않는다. contribution은 type token/order/schema/hash만 제공하고 +JSON mapper, deterministic writer, parser와 schema validator는 계속 outbound messaging leaf가 +소유한다. 따라서 feature mapper가 JSON string/tree를 만들거나 messaging leaf가 sample class를 +compile-time import할 필요가 없다. + +### 9.9 외부 deployment asset + +다음은 application Java leaf가 아니라 deployment/integration-test asset이다. + +- Kafka cluster/topic/ACL provisioning; +- Kafka Connect worker; +- Debezium connector JSON; +- PostgreSQL publication/replication-slot procedure; +- connector image/plugin digest; +- dashboard/alert/runbook. + +## 10. Capability card와 exact selection + +### 10.1 두 층의 card + +Semantic card는 application이 요구하는 의미를 나타낸다. + +| Card | 의미 | +| --- | --- | +| `messaging-best-effort-publish.v1` | persistence/replay 보장 없는 bounded attempt | +| `messaging-outbox-publish.v1` | transactional append 뒤 ACK 또는 explicit disposition까지 추적 | +| `messaging-inbox-consume.v1` | future inbox-covered idempotent application effect | +| `messaging-cdc-dispatch.v1` | future insert-only source log dispatch | + +Provider/profile card는 그 의미를 실제로 제공하는 조합이다. + +| Card | 초기 상태 | +| --- | --- | +| `external-kafka-sender-legacy.v1` | R0, R2 selection 금지 | +| `kafka-spring-acknowledged-idempotent.v1` | 최초 R2 목표 | +| `postgresql-polling-outbox.v2` | 최초 R2 목표 | +| `json-schema-envelope.v1` | 최초 R2 목표 | +| `external-topic-validated.v1` | 최초 R2 목표 | +| `postgresql-per-record-jit-claim.v1` | 최초 R2 목표 | +| `kafka-sasl-ssl-scram-sha-512.v1` | 최초 production R2 목표 | +| `kafka-compression-none.v1` | 최초 R2 목표 | +| `per-key-normal-path-sequence-detectable.v1` | 최초 R2 목표 | +| `same-postgresql-transaction-resource.v1` | 최초 R2 목표 | +| `authenticated-internal-web-disposition.v1` | 최초 R2 목표 | + +미래 consumer/CDC/EOS/Avro/Protobuf semantic/provider 이름은 §31.5의 **설계 extension +ledger**일 뿐 machine +registry row가 아니다. code, exact settings, test와 evidence가 생기는 변경에서만 machine +registry에 추가한다. + +### 10.2 Card 필드 + +각 executable card는 최소 다음을 가진다. + +```text +cardId +cardVersion +semanticContractIds +providerId +providerVersion +maturity +guarantees +explicitNonGuarantees +outcomeTaxonomyVersion +orderingProfile +resourceBounds +automaticPublicationAge +sameEventRequeueHorizon +securityProfile +topologyProfile +lifecycleProfile +operatorControlProfile +schemaSetHash +settingsDigest +evidenceFingerprint +evidenceTasks +requiredScenarios +runbookIds +owner +``` + +`maturity`는 정확히 다음 하나다. + +```text +not-implemented +implemented-candidate +release-eligible +``` + +별도 boolean `releaseEligible`이나 중복 maturity/readiness 필드를 두지 않는다. R0–R3는 +evidence 설명용 level이고 machine selection state를 대신하지 않는다. + +### 10.3 Selection 규칙 + +```text +required semantic contract/version 일치 +AND required guarantees ⊆ provider achieved guarantees +AND outcome/failure policy compatible +AND exact dispatch/serialization/security/topic/operator-control profiles compatible +AND automatic publication/requeue/dedupe horizons compatible +AND profile.maturity = release-eligible +AND current evidence fingerprint = PASS +``` + +하나라도 불충족하면 production ACTIVE startup 또는 release gate를 실패시킨다. + +### 10.4 First R2 selected tuple + +```text +messaging-outbox-publish.v1 + + kafka-spring-acknowledged-idempotent.v1 + + postgresql-polling-outbox.v2 + + postgresql-per-record-jit-claim.v1 + + json-schema-envelope.v1 + + external-topic-validated.v1 + + kafka-sasl-ssl-scram-sha-512.v1 + + kafka-compression-none.v1 + + per-key-normal-path-sequence-detectable.v1 + + same-postgresql-transaction-resource.v1 + + authenticated-internal-web-disposition.v1 +``` + +local/development는 별도 `local-plaintext.v1` evidence를 가질 수 있지만 production tuple에 +승격되지 않는다. + +### 10.5 Configuration과 registry의 역할 + +- `src/config/messaging/readiness-cards.yaml`: 구현된 card의 maturity와 base scenario; +- `src/config/messaging/profile-compatibility.yaml`: wildcard 없는 exact tuple과 interaction + scenario; +- `src/config/messaging/release-profile-assertions.yaml`: 실제 release configuration digest와 + expected selected profile assertion; +- deployment binding: 이 deployment가 어떤 exact card와 logical destination을 요구하는지 선언; +- compiled descriptor: 두 입력을 합성한 실제 runtime truth; +- §0 status ledger: 구현 phase 진행의 human truth. + +설정에 `provider=avro` 같은 값을 추가하는 것만으로 optional card가 생기지 않는다. registry에 +없는 값은 unknown configuration으로 startup 실패다. + +candidate 승격 deadlock을 피하기 위해 isolated test harness에만 `QUALIFICATION_ONLY`를 둔다. +production과 같은 binder/resolver/resource composition을 사용하되 +`implemented-candidate`를 허용하고, `ACTIVE_READY`, release assertion 또는 production +descriptor는 절대 만들지 않는다. machine registry 파일은 P1–P4 구현과 함께 생성하며, 현재 +설계 extension 이름만 미리 row로 만들지 않는다. + +## 11. Identity, ordering과 vocabulary + +### 11.1 Event ID + +`eventId`는 integration event의 canonical identity다. + +- globally unique하고 immutable하다; +- first card의 wire/storage grammar는 1–96자의 canonical US-ASCII + `[A-Za-z0-9][A-Za-z0-9._:-]*`이며 DB에는 `VARCHAR(96)` + CHECK로 저장한다; +- outbox retry, producer restart, polling/CDC mode가 바뀌어도 동일하다; +- Kafka producer attempt ID나 database row ID와 다르다; +- consumer inbox dedupe의 기본 identity다; +- payload와 함께 생성된 뒤 다시 계산하지 않는다. + +현재 `idempotencyKey`는 권장값이 `eventId`이고 별도 의미가 불명확하다. 목표 contract에서는 +consumer dedupe는 `eventId` 하나를 사용한다. 원본 command의 idempotency identity가 필요하면 +`sourceOperationId`처럼 의미가 다른 이름으로 보존하며 consumer dedupe key로 자동 대체하지 않는다. + +같은 `eventId`와 다른 exact envelope document hash가 관찰되면 정상 duplicate가 아니라 identity collision 또는 +contract violation이다. consumer는 이를 `DUPLICATE`로 ACK하지 않고 quarantine/operator path로 +보낸다. + +### 11.2 Contract ID와 event name + +`contractId`는 version과 분리된 안정적인 semantic name이다. + +예: + +```text +portfolio.worklog.reserved +portfolio.poster.published +``` + +규칙: + +- closed code/manifest catalog에 등록한다; +- user/tenant/request 입력으로 동적 생성하지 않는다; +- Java class name과 자동 결합하지 않는다; +- physical topic을 포함하지 않는다; +- metric tag로 사용할 때 catalog cardinality budget을 통과해야 한다. + +`eventType` legacy field는 migration 동안 contract ID alias로 읽을 수 있지만 새 event에는 +`contractId`를 사용한다. + +### 11.3 Envelope version과 payload version + +두 version을 분리한다. + +```text +envelopeVersion = messaging 공통 metadata shape version +payloadVersion = contractId별 business payload schema version +``` + +단일 `schemaVersion`으로 두 의미를 합치지 않는다. + +- envelope version 변경은 모든 producer/consumer/CDC mapping에 영향을 준다; +- payload version 변경은 특정 contract에만 영향을 준다; +- schema file은 version별 immutable하다; +- 같은 version file의 checksum 변경은 CI 실패다. + +### 11.4 Logical destination과 physical topic + +`logicalDestinationId`는 application/contract가 요구하는 delivery class를 나타낸다. +`physicalTopic`은 deployment binding이다. + +```text +contractId + -> logicalDestinationId + -> environment-specific physicalTopic +``` + +logical destination은 retention class, ordering class, maximum record size, sensitivity, +replay horizon과 같은 semantic/operational intent를 묶는다. topic 이름, cluster bootstrap server, +ACL principal은 포함하지 않는다. + +### 11.5 Aggregate identity와 total order + +ordering을 요구하는 contract는 다음을 가진다. + +```text +aggregateType +aggregateId +aggregateOrder = (aggregateSequence, eventIndex) +``` + +- `aggregateSequence`는 domain aggregate version 또는 같은 transaction에서 allocation한 + monotonically increasing sequence다; +- 하나의 aggregate version에서 여러 integration event가 나오면 `eventIndex`로 total order를 + 완성한다; +- 더 단순한 구현이 event마다 고유 단조 sequence를 할당하면 `eventIndex=0`으로 고정할 수 있다; +- `(tenant?, logicalDestinationId, aggregateType, aggregateId, + aggregateSequence, eventIndex)`는 unique constraint로 보호한다; +- sequence를 제공할 수 없는 event는 strict aggregate ordering card를 선택할 수 없다. + +timestamp와 random event ID는 strict total order의 대체물이 아니다. + +tenant scope는 nullable uniqueness에 맡기지 않는다. + +- tenant mode ACTIVE: canonical `tenant_scope`는 `NOT NULL`이고 unique key에 포함한다; +- tenant mode DISABLED: canonical non-null system scope를 저장하거나 tenant column을 제외한 별도 + constraint를 사용한다; +- 일반 PostgreSQL `UNIQUE`의 NULL-distinct 동작에 dedupe/order correctness를 의존하지 않는다; +- `NULLS NOT DISTINCT`를 선택하면 adopted PostgreSQL version과 migration test에 명시한다. + +### 11.6 Partition key + +partition key는 catalog가 정한 deterministic mapping이다. + +기본 ordered event: + +```text +partitionKeyText = + lowerHex( + SHA-256( + UTF8("ca-skeleton.messaging.partition-key.v1") || 0x00 + || u32be(len(UTF8(tenantScope))) || UTF8(tenantScope) + || u32be(len(UTF8(logicalDestinationId))) || UTF8(logicalDestinationId) + || u32be(len(UTF8(aggregateType))) || UTF8(aggregateType) + || u32be(len(UTF8(aggregateId))) || UTF8(aggregateId) + ) + ) +partitionKeyBytes = US_ASCII(partitionKeyText) +``` + +규칙: + +- `tenantScope`는 §11.5의 canonical non-null scope다; +- `u32be`는 뒤따르는 UTF-8 byte length의 unsigned 32-bit big-endian 표현이다; +- 결과는 정확히 64자의 lowercase hexadecimal text이고 DB에는 + `VARCHAR(64) NOT NULL` + lowercase-hex CHECK로 저장한다; +- 같은 ordering scope는 동일 text/bytes를 만든다; +- raw PII/tenant/user ID를 metric/log에 노출하지 않는다; +- null/blank key는 ordering-required contract에서 startup/runtime rejection이다; +- polling producer는 저장된 text의 US-ASCII bytes를 `ByteArraySerializer`로 보내고, CDC는 같은 + PostgreSQL `VARCHAR`를 Kafka Connect `StringConverter`로 보내 같은 bytes를 만든다; +- producer와 CDC가 domain-separated length-prefix golden vector를 공유한다; +- custom partitioner가 key를 무시하면 해당 ordering card는 invalid다. + +### 11.7 Attempt, claim과 generation + +다음 identity는 event ID와 다르다. + +| Identity | 용도 | +| --- | --- | +| `claimToken` | polling row의 현재 owner를 fence하는 opaque token | +| `deliveryGeneration` | operator replay/requeue가 만든 새 delivery lifecycle | +| `publicationAttemptId` | 한 application-level send attempt 진단 | +| `producerGeneration` | credential/settings rotation으로 생성된 producer runtime | +| `consumerId` | inbox effect identity | +| `replayOperationId` | audited replay 요청 | + +attempt/generation을 consumer dedupe event ID로 사용하지 않는다. + +### 11.8 Consumer identity + +future inbox의 `consumerId`는 최소 다음을 compile한다. + +```text +logical subscription ++ handler name ++ effect contract version ++ tenant dimension when storage is tenant-isolated +``` + +Kafka group ID가 배포 편의 때문에 바뀌어도 의도하지 않은 business effect 재적용이 일어나지 +않도록 logical identity를 명시한다. group ID를 consumer identity에 포함해야 하는 deployment는 +그 관계를 descriptor에 고정한다. intentional reprocessing은 새 `replayGeneration`과 승인을 +요구한다. + +### 11.9 Clock authority + +- `occurredAt`: event가 일어난 application/domain wall-clock fact; +- `createdAt`: database insert time; +- claim lease, retry due, retention cutoff: database time authority; +- producer deadline: monotonic process clock; +- broker record timestamp: event timestamp policy 또는 broker append time descriptor. + +여러 pod의 wall-clock으로 claim lease를 판정하지 않는다. database time을 사용하지 못하면 허용 +clock-skew bound와 failure policy를 card에 포함한다. + +## 12. Integration event pipeline + +### 12.1 Domain event와 integration event + +Domain event를 그대로 JSON으로 직렬화하지 않는다. + +```text +DomainEvent + -> feature application mapper + -> IntegrationEventDraft + -> bounded local contract compiler/encoder + -> ValidatedIntegrationEvent + -> immutable outbox_event + -> WireEnvelope v1 +``` + +feature application mapper가 소유하는 것은 외부에 공개할 semantic field 선택이다. encoder가 +소유하는 것은 UTF-8 JSON encoding, schema validation, byte bound와 checksum이다. mapper에 +retry/topic/security 정책을 넣지 않고 encoder에 business rule을 넣지 않는다. + +### 12.2 Draft + +개념적인 draft shape는 다음과 같다. + +```java +record IntegrationEventDraft

( + EventId eventId, + ContractId contractId, + int payloadVersion, + LogicalDestinationId destinationId, + AggregateIdentity aggregate, + AggregateOrder order, + Instant occurredAt, + CorrelationId correlationId, + Optional causationId, + Optional tenantId, + P featurePayload) {} +``` + +이는 구현 이름을 강제하는 Java API가 아니라 ownership을 보여주는 pseudocode다. +`featurePayload`는 §9.8 contribution의 exact type token으로 등록된 typed immutable Java +record다. JSON tree, Jackson node, raw map/string, Kafka record가 application contract가 되지 +않는다. + +### 12.3 Local contract compiler/encoder + +Application은 framework-free port를 통해 deterministic local encoder를 사용할 수 있다. 실제 +JSON/schema library는 outbound messaging adapter가 소유한다. + +encoder는: + +- startup에 schema/catalog를 precompile한다; +- runtime remote schema fetch를 하지 않는다; +- bounded CPU/memory 안에서 typed payload를 JSON으로 encode한다; +- envelope/payload schema, duplicate key, depth와 exact UTF-8 bytes를 검증한다; +- immutable serialized document와 schema/catalog digest를 반환한다. + +first encoder는 같은 logical event가 같은 exact UTF-8 document를 만들도록 deterministic field +order와 scalar rendering을 고정한다. 저장·재발행·CDC의 authority는 이 exact byte document이며 +JSONB 재직렬화 결과가 아니다. + +business transaction 안에서 호출될 경우 local computation만 수행하고 network, broker, +filesystem, secret refresh를 하지 않는다. encoding 비용이 transaction budget을 넘는 event는 +transaction 전에 immutable input을 준비하거나 별도 staged workflow를 사용한다. + +### 12.4 Transaction sequence + +durable application command의 기본 순서는 다음이다. + +```text +1. command/idempotency/authorization validation +2. tx.inWrite begin +3. domain aggregate load + invariant check + mutation +4. domain event -> integration-event draft mapping +5. precompiled local encoder validation +6. business state save +7. outbox_event INSERT +8. polling mode이면 outbox_delivery INSERT +9. commit +``` + +4–8 중 하나라도 실패하면 business write도 rollback한다. broker send는 이 transaction 안에서 +수행하지 않는다. + +same-store는 이름뿐인 가정이 아니다. compiled card는 `transactionResourceId`를 갖고 business +repository, `TransactionPort`, `OutboxAppendPort`, outbox migration이 같은 resolved +`DataSource`/`EntityManagerFactory`/`PlatformTransactionManager` resource에 bind됐는지 startup에 +검증한다. multi-datasource deployment는 contract별 resource binding을 명시한다. 다른 resource면 +ACTIVE를 거부한다. real rollback test가 이 identity assertion을 보완한다. + +dispatch mode 판단은 feature mapper가 하지 않는다. persistence append adapter가 같은 +transaction에서 §27의 active publication epoch를 읽고 event에 epoch/authority를 기록한 뒤, +`POLLING_V2`일 때만 delivery row를 함께 만든다. + +### 12.5 Validated event와 stored event + +`ValidatedIntegrationEvent`는 최소 다음을 가진다. + +```text +all stable identities +envelopeVersion +payloadVersion +logicalDestinationId +partitionKeyText and its exact US-ASCII bytes +validated envelope JSON bytes/document +contentType +schemaSetHash +envelopeSha256 +envelopeSchemaHash +payloadSchemaHash +contractCatalogRevision +destinationBindingRevision +validated traceparent/tracestate allowlist +``` + +`publicationEpoch`, `dispatchAuthority`, `transactionResourceId`, DB-authoritative `createdAt`은 +encoder 결과가 아니다. `OutboxAppendPort`의 persistence 구현이 caller의 write transaction +안에서 ACTIVE epoch를 읽고 same-store resource identity를 확인한 뒤 이 네 값을 더해 +`StoredOutboxEvent`를 구성한다. 따라서 transaction 전에 만들어 둔 validated bytes가 stale +application setting의 authority를 내장하거나 application clock을 DB creation time으로 가장하지 +않는다. + +retry 때 payload를 다시 business object에서 직렬화하지 않는다. polling attempt는 저장된 같은 +identity와 validated document를 사용한다. + +`envelopeSha256`은 다음 exact input으로 계산한다. + +```text +SHA-256( + UTF8("ca-skeleton.messaging.envelope.v1") || 0x00 + || u32be(len(exactEnvelopeBytes)) + || exactEnvelopeBytes +) +``` + +여기서 `u32be`는 §11.6과 같은 unsigned 32-bit big-endian byte length다. +이는 integrity/collision diagnosis용이지 confidentiality control이 아니다. DB/API/log/metric에 +노출하지 않고 payload와 같은 access control/retention을 적용한다. 같은 event ID에서 다른 +envelope hash는 duplicate가 아니라 collision/quarantine이다. semantic JSON을 JSONB로 round-trip한 +뒤 다시 hash하지 않는다. + +### 12.6 Polling/CDC wire parity + +polling과 CDC는 같은 logical `WireEnvelope v1`을 emit한다. + +- field와 semantic value가 같아야 한다; +- event ID, contract ID, versions, key가 같아야 한다; +- JSON object member byte ordering 차이를 허용할지 card가 명시한다; +- first baseline은 polling retry에서 exact stored UTF-8 bytes 재사용을 요구한다; +- CDC는 golden semantic equality와 consumer decode equality를 통과한다; +- “같은 contract”를 단순히 비슷한 JSON이라고 표현하지 않는다. + +## 13. Contract catalog, destination binding과 topic + +### 13.1 두 catalog + +Contract catalog는 code/repository artifact다. + +```text +contractId +payload versions +owner module +logical destination +payload schema resource/hash +serializer id +ordering requirement +partition-key policy +maximum payload/envelope bytes +sensitivity classification +supported producer/consumer version matrix +same-event requeue horizon +``` + +Destination binding은 deployment configuration다. + +```text +logical destination +Kafka cluster binding +physical topic +expected partitions +minimum replication factor +minimum in-sync replicas +cleanup policy +retention expectation +maximum record bytes +security profile +required readiness +``` + +contract가 infrastructure topology를 소유하지 않고 configuration이 business schema를 +재정의하지 않는다. + +### 13.2 Compile + +startup compiler는 다음을 합성한다. + +```text +contract descriptor ++ destination descriptor ++ producer provider descriptor ++ serialization descriptor ++ security descriptor ++ evidence card += CompiledPublicationBinding +``` + +검증: + +- contract/destination ID unique; +- 모든 active contract에 정확히 한 destination binding; +- unknown destination/topic 금지; +- ordering-required contract에 nonblank stable key; +- contract maximum bytes <= destination/provider/topic bounds; +- schema/catalog hash가 evidence와 일치; +- production profile과 security profile 호환; +- dispatch mode와 provider 요구 일치; +- required binding은 release-eligible evidence 보유. + +### 13.3 Configuration override 제한 + +설정은 code contract를 약화하지 못한다. + +- code maximum record bytes보다 크게 override할 수 없다; +- ordering-required를 `NONE`으로 낮출 수 없다; +- schema validation을 끌 수 없다; +- production TLS 요구를 plaintext로 바꿀 수 없다; +- required destination을 optional로 바꿀 수 없다; +- unknown compatibility mode를 선택할 수 없다. + +더 엄격한 deployment bound는 허용한다. + +### 13.4 Topic naming + +physical topic은 operator-owned static value다. + +- request/event/tenant 값을 문자열 보간하지 않는다; +- environment prefix/suffix는 binding compiler가 allowlist pattern으로 검증한다; +- producer principal은 production에서 Create/Delete/Alter 권한을 갖지 않는다; +- auto-create를 끈다; +- topic rename은 새 binding/revision과 migration runbook을 요구한다. + +### 13.5 Topic topology attestation + +ACTIVE startup 또는 pre-deploy gate는 최소 다음을 확인한다. + +- topic 존재; +- expected partition count; +- replication factor가 minimum 이상; +- `min.insync.replicas`가 policy minimum 이상; +- cleanup policy; +- retention/replay horizon; +- topic maximum message bytes; +- unclean leader election 관련 cluster/topic policy가 deployment 요구와 호환; +- producer principal의 최소 Describe/Write 동작; +- consumer/DLT profile이 있을 때 대응 Read/Write ACL. + +`acks=all`만 확인하고 replication/min ISR를 보지 않은 상태를 durable topology로 표시하지 않는다. + +first tuple은 verification source를 항목별로 고정한다. + +| 항목 | Runtime source | Release/provisioning source | +| --- | --- | --- | +| cluster identity, topic existence, partition/leader/ISR/RF | producer principal의 bounded AdminClient `Describe` | IaC expected resource identity | +| topic cleanup/retention/max bytes/min ISR/topic override | exact topic 범위 read-only `DescribeConfigs` | IaC rendered config/digest | +| broker-level unclean election/default/max bounds/auto-create | runtime에서 과도한 cluster config 권한을 요구하지 않음 | signed/provenance-attested broker policy | +| exact-topic Write와 denied Create/Alter/Delete/other-topic Write | startup에 임의 canary를 만들지 않음 | security release lane의 positive/negative probe | +| ACL/quota owner와 rollback | runtime ACL enumeration 금지 | IaC/security evidence | + +release/provisioning evidence는 environment/cluster alias, topic resource identity, rendered +config/ACL policy digest, issuer/provenance, generated-at, expires-at와 release assertion digest를 +가진다. missing, stale, wrong-cluster, signature/provenance failure 또는 runtime-observed 값과의 +mismatch는 production ACTIVE를 fail-closed한다. runtime에서 확인할 수 없는 값을 “검증됨”으로 +표시하지 않고 descriptor에 source와 freshness를 함께 노출한다. + +### 13.6 Partition expansion + +Kafka default key partitioning에서 partition 수가 바뀌면 같은 key가 다른 partition으로 이동할 수 +있다. rolling producer/consumer 기간에는 old/new partition의 event order가 섞일 수 있다. + +ordering-required topic은 in-place partition expansion을 일반적인 무중단 변경으로 취급하지 +않는다. 기본 절차는 새 topic/binding generation, write cutover watermark, consumer dual-read +또는 drain, order reconciliation과 rollback이다. + +event append 시 `destinationBindingRevision`을 immutable capture한다. retry는 같은 revision을 +resolve하며 current config의 새 topic으로 조용히 reroute하지 않는다. backlog를 새 binding으로 +옮기려면 audited delivery generation/explicit migration을 사용한다. + +### 13.7 Compaction + +first baseline topic은 delete-retention event log다. compaction은 다음이 모두 정의된 contract만 +별도 card로 선택한다. + +- key가 entity state identity인지; +- tombstone 의미; +- intermediate event 손실 허용 여부; +- consumer bootstrap 의미; +- minimum compaction lag; +- delete retention; +- replay/ordering 영향. + +integration event에 compaction을 기본 적용하지 않는다. + +## 14. JSON envelope v1과 schema evolution + +### 14.1 Dialect와 resource ownership + +first baseline은 JSON Schema Draft 2020-12를 사용한다. + +공통 envelope schema 예시 위치: + +```text +src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json +``` + +sample payload schema 예시 위치: + +```text +src/sample-portfolio/src/main/resources/contracts/messaging/ + portfolio.worklog.reserved/v1.schema.json +``` + +실제 production project는 feature owner module에 payload schema를 둔다. 각 schema는: + +- explicit `$schema`; +- immutable absolute `$id`; +- contract/payload version; +- checked-in checksum manifest; +- local prebundled `$ref` allowlist; +- owner와 compatibility vectors를 가진다. + +runtime HTTP/file remote `$ref` resolution은 금지한다. + +### 14.2 Envelope shape + +개념적인 envelope v1: + +```json +{ + "envelopeVersion": 1, + "eventId": "019...", + "contractId": "portfolio.worklog.reserved", + "payloadVersion": 1, + "logicalDestination": "portfolio-domain-events", + "aggregate": { + "type": "worklog", + "id": "worklog-42", + "sequence": 17, + "eventIndex": 0 + }, + "occurredAt": "2026-07-28T05:10:30.123Z", + "correlationId": "corr-...", + "causationId": "cause-...", + "contentType": "application/json", + "payload": { + "workLogId": "worklog-42" + } +} +``` + +tenant가 실제로 활성인 deployment만 bounded `tenantId`를 포함한다. causation ID가 없을 때 +null로 넣을지 field를 생략할지는 envelope schema가 하나로 고정한다. + +envelope에는 다음을 넣지 않는다. + +- physical topic/cluster/bootstrap server; +- delivery status/attempt/backoff/claim token; +- Kafka offset/partition; +- credential/security profile; +- Java class name; +- raw exception; +- arbitrary baggage; +- mutable consumer state. + +### 14.3 Envelope/header ownership + +authoritative semantic metadata는 envelope다. Kafka header는 transport 기능에 필요한 bounded +allowlist만 사용한다. + +초기 header allowlist 후보: + +```text +id +contract-id +payload-version +traceparent +tracestate +``` + +first header의 `id`는 Debezium Outbox Event Router와 polling producer가 공유하는 event ID다. +envelope와 header에 중복된 identity가 다르면 producer와 consumer 모두 reject한다. header 이름, +개수, key bytes, total bytes를 제한한다. arbitrary inbound header forwarding은 금지한다. + +### 14.4 Strict versioning + +first baseline은 `STRICT_VERSIONED` 정책을 사용한다. + +- 같은 schema version file을 수정하지 않는다; +- optional field 추가도 새 payload version을 만든다; +- consumer가 새 version을 지원한 뒤 producer를 배포한다; +- rolling overlap 동안 consumer는 최소 명시된 N/N-1 version allowlist를 가진다; +- producer가 지원 종료된 version을 emit하지 않는다는 release assertion을 둔다; +- unsupported future version은 일반 retry 대상이 아니다. + +JSON Schema diff heuristic만으로 backward/full compatibility를 주장하지 않는다. 실제 old/new +reader/writer golden vectors가 compatibility evidence다. Kafka/DLT/archive/inbox replay horizon +안에 남아 있는 **모든** payload version은 reader support를 유지한다. version retirement는 +해당 version의 source/DLT/archive가 더 이상 replay 가능하지 않거나 versioned upcaster가 +qualification됐다는 purge proof가 있어야 한다. N/N-1은 replay horizon을 대신하지 않는다. + +### 14.5 Object와 unknown-field policy + +- envelope v1은 `unevaluatedProperties: false`로 닫는다; +- payload schema도 first baseline에서 explicit property set을 사용한다; +- additive evolution은 in-place field 추가가 아니라 payload version 증가로 처리한다; +- tolerant-reader card를 나중에 추가할 수 있지만 그때 unknown-field behavior와 rolling vectors를 + 별도 증명한다. + +### 14.6 Scalar/collection policy + +각 schema가 표현할 수 있는 범위는 최소 다음을 명시한다. + +- `required`; +- null과 missing의 차이; +- string `minLength/maxLength`와 Unicode normalization policy; +- array maximum items; +- integer/number semantic range; +- enum evolution; +- timestamp string format; +- object property count; + +JSON Schema `format`은 implementation에 따라 annotation일 수 있다. runtime validator에서 format +assertion을 켰다는 evidence를 만들거나 timestamp/UUID 등을 explicit parser로 검증한다. + +Draft 2020-12가 직접 표현하지 않는 exact UTF-8 byte 수, nesting depth, numeric precision, +exponential notation/canonical lexical form, parser time/memory는 +`json-codec-admission-v1`의 별도 규칙이다. `maxLength`를 byte limit으로 오해하거나 custom +keyword 없이 schema가 이 제한을 증명한다고 쓰지 않는다. + +### 14.7 Parser hardening + +codec은 다음을 거부한다. + +- malformed UTF-8; +- duplicate object member names; +- unpaired surrogate; +- excessive nesting; +- maximum을 넘는 string/array/object; +- resource budget을 넘는 arbitrary-precision number; +- trailing garbage; +- non-finite number; +- remote reference; +- polymorphic Java type metadata. + +validation CPU/memory/time budget을 test한다. record size는 Java character 수가 아니라 최종 UTF-8 +key + value + header bytes로 계산한다. + +schema compiler는 exact offline resource registry만 사용한다. + +- duplicate `$id`/unknown vocabulary/unknown dialect 거부; +- remote URI가 local allowlist resource로 정확히 resolve되지 않으면 거부; +- cyclic/recursive `$ref`는 명시적 depth/resource budget 안에서 지원하거나 compile-time 거부; +- pathological regular expression/validator recursion adversarial corpus; +- meta-schema와 vocabulary도 pinned local checksum 대상; +- startup precompile 뒤 runtime schema fetch 0. + +### 14.8 Envelope document hash + +§12.5의 exact envelope byte hash는: + +- same event ID document collision 탐지; +- polling retry exact-document 확인; +- CDC shadow byte parity; +- audit/diagnosis + +에 사용한다. algorithm/input은 §12.5 하나만 정본으로 사용한다. hash를 metric tag로 쓰지 않고 +restricted storage 밖에 노출하지 않는다. + +### 14.9 Schema registry future card + +Avro/Protobuf/JSON Schema registry card가 추가되면 다음을 별도로 설계·검증한다. + +- subject naming; +- compatibility mode; +- registry auth/TLS/readiness; +- schema ID cache와 outage behavior; +- generated code ownership; +- rolling compatibility; +- registry unavailable 시 write policy; +- schema deletion/retention; +- cross-cluster replication. + +first JSON Schema card에는 registry 설정 key나 placeholder를 추가하지 않는다. + +## 15. Application contract와 publication outcome + +### 15.1 Durable append port + +`OutboxAppendPort`는 provider-neutral same-store append 의미를 유지한다. target input은 +legacy raw `String payload`가 아니라 validated integration event다. + +```java +interface OutboxAppendPort { + void append(ValidatedIntegrationEvent event); +} +``` + +실제 이름은 implementation plan에서 정하지만 다음은 변하지 않는다. + +- application/core 타입; +- same transaction requirement; +- provider/physical topic 없음; +- immutable event identity; +- validation/catalog evidence 포함. + +### 15.2 Publish port + +target publish port는 expected technical outcome을 exception 하나로 뭉치지 않는다. + +```java +sealed interface PublicationOutcome { + record Acknowledged(PublicationReceipt receipt) implements PublicationOutcome {} + record AcknowledgedMismatch(PublicationReceipt receipt) implements PublicationOutcome {} + record Rejected(PublicationFailure failure) implements PublicationOutcome {} + record Indeterminate(PublicationFailure failure) implements PublicationOutcome {} +} +``` + +`PublicationReceipt`는 Kafka SDK 타입 대신 다음 같은 bounded provider-neutral reference를 +가진다. + +```text +providerId +logicalDestinationId +providerGeneration +ackObservedAt +opaque bounded providerRecordReference +``` + +physical topic/partition/offset가 persistence audit에 필요하면 adapter가 safe bounded string으로 +만들며 application이 이를 routing에 사용하지 않는다. `ackObservedAt`은 broker clock이 아니라 +local future-completion 관찰 시각이다. + +#### Late completion observation boundary + +동기 publish 결과가 `INDETERMINATE`로 반환된 뒤 Kafka future가 완료될 수 있으므로 다음 +provider-neutral application ports를 둔다. + +```java +interface LatePublicationObservationSourcePort { + List pollBounded(int maximum); + void acknowledgePersisted(ObservationId id); + void releaseForRetry(ObservationId id); +} + +interface OutboxAttemptObservationPort { + void appendLateObservations(List observations); +} +``` + +- outbound messaging adapter가 bounded payload-free queue로 source port를 구현한다; +- persistence adapter가 append port를 구현한다; +- application의 `RecordLatePublicationObservationsUseCase`는 다음 순서를 고정한다: + `poll/lease bounded batch -> tx.inNew(idempotent append) 정상 반환 -> acknowledgePersisted`. + `tx.inNew(...)`의 정상 반환은 commit 완료를 뜻하며 source ACK를 transaction callback 안에서 + 호출하지 않는다; +- `app-bootstrap`은 provider ACTIVE일 때만 bounded drain scheduler를 조립한다; +- Kafka callback thread는 JPA/repository/transaction을 직접 호출하지 않는다; +- observation identity는 + `(eventId, deliveryGeneration, publicationAttemptId, LATE_ACK_OBSERVED)`이고 DB unique/ON + CONFLICT로 duplicate drain을 흡수한다; +- callback/timeout은 adapter-local atomic terminal marker로 단 한 synchronous outcome을 + 결정한다. deadline marker가 먼저 이기고 ACK가 나중에 오면 queue에 late observation 하나만 + 제안한다; +- DB append 또는 commit 실패는 `releaseForRetry`로 item을 bounded retry에 되돌리고 delivery + state를 바꾸지 않는다; +- DB commit 뒤 source ACK 전 process crash는 같은 observation의 duplicate drain을 만들 수 + 있으며 DB unique/`ON CONFLICT`가 이를 흡수한다. + +attempt admission과 deadline 시점의 `INDETERMINATE` outcome journal은 authoritative하고 반드시 +Tx B/Tx C에서 영속화한다. 반면 process crash나 queue overflow로 late callback diagnostic 자체를 +잃을 수 있으므로 `LATE_ACK_OBSERVED` capture를 delivery correctness 근거로 사용하지 않는다. +queue overflow/drop은 bounded metric, readiness degradation과 alert 대상이며 capacity +qualification에서는 0이어야 한다. 이 경계 덕분에 messaging→persistence project dependency를 +추가하지 않는다. + +### 15.3 Failure stage + +최소 stage: + +```text +CONTRACT_COMPILE +SERIALIZATION +LOCAL_ADMISSION +METADATA +SEND +BROKER_ACK +DEADLINE +SHUTDOWN +PROVIDER +``` + +최소 failure class: + +```text +INVALID_CONTRACT +INVALID_PAYLOAD +RECORD_TOO_LARGE +DESTINATION_MISSING +UNAUTHORIZED +AUTHENTICATION_FAILED +TLS_FAILED +TOPIC_POLICY_MISMATCH +BUFFER_EXHAUSTED +BROKER_UNAVAILABLE +THROTTLED +DEADLINE_EXCEEDED +CLIENT_CLOSED +UNKNOWN_PROVIDER_FAILURE +``` + +exception class name이나 message를 stable application contract로 사용하지 않는다. + +### 15.4 Certainty와 retry disposition + +acceptance certainty와 retryability는 독립 축이다. + +```text +acceptanceCertainty = NOT_ACCEPTED | ACCEPTED | INDETERMINATE | ACCEPTED_MISMATCH +retryDisposition = NO_RETRY | RETRY_WITHIN_BUDGET | STOP_PROVIDER | OPERATOR_HOLD +``` + +`REJECTED`는 provider가 broker acceptance가 없음을 확정할 수 있을 때만 사용한다. + +예: + +- schema/size/local catalog rejection; +- local admission 전 rejection; +- definitive broker authorization rejection; +- startup AdminClient attestation이 send admission 전에 확정한 missing destination. + +`INDETERMINATE` 예: + +- send 뒤 deadline; +- ACK response loss; +- connection break after request write; +- callback/cancel race; +- shutdown 중 unresolved in-flight; +- `NotEnoughReplicasAfterAppendException`처럼 append 뒤 실패할 수 있는 broker 응답; +- post-admission `UnknownTopicOrPartitionException`, retriable/unknown producer exception; +- provider가 acceptance를 증명할 수 없는 unknown exception. + +Kafka `RetriableException`이라는 사실은 미수락 증거가 아니다. 분류가 애매하면 +`INDETERMINATE`가 안전한 기본이다. retry 여부는 certainty를 바꾸지 않고 remaining +attempt/elapsed budget과 producer health로 결정한다. + +### 15.5 Relay decision + +| Publication outcome | Polling action | +| --- | --- | +| ACKNOWLEDGED | claim-token/valid-lease CAS로 `DELIVERY_RECORDED` 기록 | +| ACKNOWLEDGED_MISMATCH | relay scope HOLD, producer readiness DOWN, misroute incident | +| definite transient REJECTED | retry budget이 남으면 RETRY_WAIT | +| definite permanent REJECTED | 즉시 EXHAUSTED/operator disposition | +| INDETERMINATE | duplicate 가능성을 기록하고 bounded retry/reconciliation path | +| programming invariant failure | cycle 실패 + readiness/alert; 일반 transient로 숨기지 않음 | + +report는 persisted transition이 성공한 뒤에만 emit한다. report adapter failure는 authoritative +state를 바꾸지 않는다. + +### 15.6 Best-effort와 durable port + +두 contract는 계속 분리한다. + +- best-effort: persistence/replay 없음, bounded attempt 뒤 failure를 삼킬 수 있음; +- durable: transactionally stored event, polling/CDC, explicit terminal disposition. + +best-effort가 내부적으로 같은 ACK-aware producer를 사용해도 durable로 승격되지 않는다. +durable append를 자동 수행하지도 않는다. + +## 16. Spring Kafka producer protocol + +### 16.1 Runtime ownership + +`adapter:outbound:messaging`이 다음을 직접 만든다. + +- `DefaultKafkaProducerFactory`; +- `KafkaTemplate`; +- bounded AdminClient/topology attestor; +- provider generation의 생성/drain/close/attestation primitive owner; +- observation convention; +- resolved credential/certificate material을 한 provider generation에 적용하는 owner. + +`adapter:outbound:messaging`은 durable delivery state나 HOLD 정책을 결정하지 않는다. +`application-core`의 rotation use case가 provider-neutral lifecycle fact를 받아 +`INDETERMINATE/HOLD` persistence와 generation barrier/admission 재개를 조정하고, +`app-bootstrap`이 secret refresh와 그 use case invocation을 compose한다. + +classpath presence나 generic `spring.kafka.bootstrap-servers=localhost:9092` default로 활성화하지 +않는다. canonical messaging binding이 ACTIVE일 때만 만든다. + +### 16.2 Adapter-private gateway + +provider-private SPI는 ACK를 표현해야 한다. + +```java +interface KafkaPublishGateway { + KafkaAttemptOutcome publish( + CompiledKafkaRecord record, + MonotonicDeadline deadline); +} +``` + +이 SPI는 outbound adapter 내부 또는 package-private다. `KafkaTemplate`, `SendResult`, +`RecordMetadata`를 application/shared에 노출하지 않는다. + +### 16.3 Send sequence + +```text +1. compiled binding lookup +2. immutable envelope/key/header byte verification +3. local admission acquire +4. ProducerRecord construction +5. KafkaTemplate.send +6. send future를 monotonic deadline까지 await +7. RecordMetadata와 expected destination 검증 +8. ACKNOWLEDGED / ACKNOWLEDGED_MISMATCH / REJECTED / INDETERMINATE map +9. admission/resource release +``` + +serialization은 prevalidated bytes를 사용하는 Kafka `ByteArraySerializer` 계열로 단순화한다. +Kafka serializer callback 안에서 business JSON serialization이나 remote schema lookup을 하지 +않는다. + +### 16.4 ACK condition + +broker ACK 관찰은 다음으로 정의한다. + +```text +future completed successfully +AND metadata is present +``` + +metadata topic이 compiled topic과 같으면 `ACKNOWLEDGED`, 다르면 +`ACKNOWLEDGED_MISMATCH`다. deadline 뒤 future가 성공해도 ACK 관찰 사실은 append-only attempt +journal drain이 성공한 경우에만 `LATE_ACK_OBSERVED`로 남으며, 이미 정한 application +outcome/delivery state를 뒤집지 않는다. drain 전 crash/overflow로 진단 관찰을 잃을 수 있다는 +§15.2의 한계가 적용된다. provider generation의 현재 선택 여부도 broker fact 자체를 바꾸지 +않는다. + +`acks=0`은 모든 selected profile에서 금지한다. first R2는 `acks=all`이다. `acks=all`은 모든 +configured replica가 아니라 당시 ISR의 ACK를 뜻하므로 §13.5의 replication/min ISR/unclean +leader policy attestation과 함께 해석한다. + +### 16.5 Deadline/cancellation/late completion + +future await deadline이 끝나면: + +- application outcome은 `INDETERMINATE`; +- `cancel()`이 broker delivery 취소를 보장한다고 가정하지 않는다; +- late callback은 §15.2의 bounded source port에 payload-free observation을 제안하고 application + drain이 성공한 경우에만 §18.3 append-only journal에 기록한다. persisted + retry/exhausted/HOLD transition을 뒤집지 않는다; +- attempt terminal state는 atomic one-way transition이다; +- late ACK와 다음 retry가 duplicate를 만들 수 있음을 관측한다. + +deadline wrapper가 worker thread만 interrupt하고 producer request를 완전히 취소하지 못한다는 +한계를 descriptor에 기록한다. + +### 16.6 Effective producer configuration + +first R2는 다음을 explicit setting과 startup assertion으로 고정한다. + +```text +acks = all +enable.idempotence = true +retries = provider recommended effectively-unbounded/MAX +max.in.flight.requests.per.connection <= 5 +delivery.timeout.ms = finite +request.timeout.ms = finite +max.block.ms = finite +buffer.memory = finite +batch.size = finite +linger.ms = finite +max.request.size = finite +``` + +그리고 다음 관계를 검증한다. + +```text +delivery.timeout.ms >= request.timeout.ms + linger.ms +application attempt budget >= + admission wait budget + max.block.ms + delivery.timeout.ms + callback/transition reserve +claim remaining lease > + application attempt budget + DB transition reserve + clock/scheduling safety margin +``` + +Kafka library default가 현재 원하는 값과 같더라도 explicit effective config assertion을 둔다. +conflicting property가 idempotence를 끄면 startup을 실패시킨다. + +`retries`를 작은 숫자로 잘라 broker retry를 임의 약화하지 않고 `delivery.timeout.ms`가 한 +physical send의 시간 budget을 지배하게 한다. `request.timeout.ms`는 selected broker의 +`replica.lag.time.max.ms`와 Kafka 권고 관계를 provisioning evidence로 검증한다. + +size는 한 줄 부등식으로 합치지 않는다. + +1. exact envelope + key + headers + record overhead가 contract record bound 안; +2. uncompressed record batch가 producer batch/request 제약 안; +3. compressed record batch가 topic `max.message.bytes`와 broker bound 안; +4. 여러 partition batch를 담을 수 있는 request가 `max.request.size` 안. + +모든 limit에 protocol/header/batch headroom을 두며 payload와 request/topic candidate를 똑같이 +1 MiB로 두지 않는다. adopted serializer/compression의 실제 encoded batch를 real broker에서 +검증한다. + +exact numeric default와 허용 범위는 implementation plan의 benchmark/fault test로 고정한다. 무한 +또는 사실상 운영 shutdown/SLO를 넘는 값은 허용하지 않는다. + +### 16.7 Retry ownership + +Kafka client는 `delivery.timeout.ms` 안에서 같은 producer send를 retry할 수 있다. relay는 하나의 +application attempt가 definite/indeterminate failure로 끝난 뒤 새 attempt를 만든다. + +```text +physical Kafka retries + inside one publicationAttemptId + +relay retries + new publicationAttemptId, same eventId and wire document +``` + +Kafka producer idempotence는 supported producer session의 client retries를 보호하지만 다음을 +제거하지 않는다. + +- producer restart 뒤 relay resend; +- ACK-to-DB gap; +- application deadline 뒤 late ACK + resend; +- polling과 CDC 이중 활성; +- operator replay. + +fatal producer exception은 acceptance certainty와 별도로 generation lifecycle을 종료한다. +authorization/unsupported-version/out-of-order-sequence 또는 adopted client가 fatal로 정의한 +상태는 즉시 new admission 차단, readiness DOWN, bounded close/recreate를 수행한다. 같은 defunct +producer를 계속 사용하지 않으며 새 generation이 application resend duplicate를 제거한다고 +주장하지 않는다. + +### 16.8 Flush + +per-message `KafkaTemplate.flush()`를 금지한다. shared producer의 다른 batch를 강제로 flush하고 +throughput/latency를 결합하기 때문이다. future completion으로 해당 record ACK를 기다린다. + +flush는 bounded shutdown/explicit maintenance에서만 사용하고 그 보장과 timeout을 test한다. + +### 16.9 Producer transaction + +first polling provider는 Kafka transaction을 사용하지 않는다. Kafka transaction card가 later +추가되면 transactional ID uniqueness, producer fencing, cache size, timeout, abort, rolling deploy, +`read_committed` consumer까지 별도 evidence를 요구한다. + +### 16.10 Producer generation과 rotation + +credential/certificate/settings rotation은 immutable producer generation 교체로 처리한다. +소유권은 둘로 나뉜다. messaging adapter는 old/new provider generation의 +pause/drain/create/attest/close primitive와 bounded fact만 제공한다. application rotation use +case는 그 fact를 바탕으로 unresolved attempt의 durable `INDETERMINATE/HOLD`, DB failure 시 전환 +차단, generation barrier와 admission 재개 정책을 소유한다. bootstrap은 secret resolver와 +application use case를 연결할 뿐 state policy를 구현하지 않는다. + +```text +1. 신규 admission과 claim을 일시 중단 +2. old generation의 admitted/in-flight future를 bounded drain +3. drain deadline의 unresolved attempt를 Tx C에서 INDETERMINATE로 기록하고 영향받은 ordering + scope를 HOLD +4. old generation을 bounded close하고 더 이상 callback을 authoritative outcome으로 사용하지 않음 +5. 새 secret generation resolve +6. 새 producer compile/start/attest +7. 모든 old attempt가 ACK/REJECTED 또는 durable INDETERMINATE라는 application terminal + observation을 가진 뒤 generation barrier 전환 +8. HOLD 없는 scope의 admission 재개; HOLD scope는 audited duplicate-risk disposition 뒤에만 재개 +``` + +한 producer object의 mutable config를 바꾸지 않는다. old/new generation metric tag는 bounded +revision이어야 하며 secret value를 포함하지 않는다. first profile은 old/new generation send를 +겹치지 않는 global barrier를 사용한다. 여기서 “resolved”는 broker acceptance가 definitively +밝혀졌다는 뜻이 아니라 state machine이 ACK/REJECTED/**INDETERMINATE** 중 하나를 durable하게 +기록했다는 뜻이다. response loss의 영원한 확정을 기다리지 않는다. + +barrier 전환 뒤 reorder-tolerant scope는 card가 허용한 bounded duplicate-aware retry를 자동 +재개할 수 있다. ordering-required scope의 indeterminate head는 HOLD를 유지하고 +§19.4 operator가 `REMEDIATE_AND_REQUEUE`, `SKIP_WITH_GAP`, `COMPENSATE` 중 하나를 선택한다. DB가 +unavailable해 INDETERMINATE/HOLD를 durable하게 기록할 수 없으면 generation 전환과 admission을 +계속 막는다. forced crash/indeterminate write 뒤 failure-path strict order는 주장하지 않고 +aggregate sequence로 gap/regression을 탐지한다. + +persistence가 없는 best-effort/direct caller는 durable HOLD 대상이 아니다. bounded drain 뒤 +unresolved outcome을 caller/telemetry에 `INDETERMINATE`로 확정해 반환하고 새 generation을 +전환하되, 자동 replay나 ordering 안전을 주장하지 않는다. + +## 17. Ordering, retry budget, resource와 lifecycle + +### 17.1 Ordering guarantee + +Kafka가 제공하는 기본 ordering 범위는 한 partition 안이다. first R2의 정상 경로는 다음을 +요구한다. + +```text +stable physical topic generation ++ stable non-null partition key ++ idempotent producer-compatible config ++ aggregate total sequence ++ single authoritative aggregate-head claim/admission ++ same ordering scope의 concurrent out-of-order send 금지 ++ partitioner.ignore.keys = false ++ unqualified custom partitioner 없음 ++ one producer generation barrier += same generation normal-path key order + failure-path sequence detectability +``` + +global order, 여러 topic 사이 order, partition expansion 중 order, operator replay와 live stream +사이 order는 보장하지 않는다. process crash, indeterminate send, forced producer rotation, +operator replay 뒤의 strict order도 첫 card 보장이 아니다. sequence metadata만으로 Kafka +append order를 강제했다고 주장하지 않는다. strict effect order가 필요하면 future +consumer-side sequence gate/reorder card를 추가한다. + +### 17.2 Polling ordering gate + +ordering-required contract의 다음 event는 같은 ordering scope의 앞선 delivery가 +`DELIVERY_RECORDED` 또는 audited `SKIPPED/COMPENSATED`일 때만 claim한다. + +`EXHAUSTED` head는 후행을 block한다. 자동 skip하지 않는다. hot aggregate가 전체 batch를 +starve하지 않도록 batch selection은 scope별 head만 후보로 삼고 destination 전체 fairness를 +관측한다. + +### 17.3 Combined amplification budget + +최악의 wire work는 대략 다음이다. + +```text +relayAttempts +× Kafka client physical retries within delivery timeout +× number of destinations +× replay generations +``` + +first baseline은 event당 destination 하나다. 설정 compiler는: + +- maximum relay attempts; +- delivery-generation DB-created-at 기준 maximum automatic publication age; +- per-attempt deadline; +- backoff/jitter; +- producer internal delivery timeout; +- shutdown budget; +- dead/exhausted transition + +을 하나의 descriptor로 계산한다. max attempt만 있고 maximum automatic publication age가 없는 정책은 +허용하지 않는다. + +### 17.4 Failure class와 retry + +| Failure | 기본 | +| --- | --- | +| invalid contract/schema/size | retry 없음, writer rejection 또는 operator path | +| auth/ACL/topic policy mismatch | readiness down, 빠른 반복 retry 금지 | +| pre-admission transient metadata/network | definite rejection일 때만 bounded retry | +| post-admission leader/network/retriable | 기본 indeterminate + bounded duplicate-aware retry | +| not-enough-replicas-after-append | indeterminate | +| throttle | broker signal과 remaining budget 안에서 retry | +| local buffer exhausted | bounded admission/backpressure, retry budget 공유 | +| deadline/response loss | indeterminate, duplicate-aware retry | +| application programming defect | fail fast/alert, transient로 숨기지 않음 | + +### 17.5 Record and memory bounds + +다음을 별도로 제한한다. + +- key bytes; +- value UTF-8 bytes; +- header count/key/value/total bytes; +- uncompressed record bytes; +- compressed batch bytes; +- batch size; +- request size; +- producer buffer memory; +- application admitted in-flight records; +- pending callback/attempt contexts. + +Kafka client `buffer.memory`는 전체 producer memory의 완전한 hard bound가 아니다. compression, +in-flight request, object overhead와 callback context를 포함한 process memory budget을 +capacity test로 계산한다. + +### 17.6 Large message + +contract maximum을 넘는 payload는 outbox에 append하지 않는다. large payload가 실제 요구되면 +object storage에 immutable object를 먼저 publish하고 checksum/size/authorization이 있는 +claim-check event를 보내는 별도 design을 사용한다. + +object upload와 DB business transaction 사이 atomicity가 없으므로 staged object, outbox, +orphan cleanup과 authorization을 함께 설계한다. 단순 URL을 Kafka에 넣는 것은 대안이 아니다. + +### 17.7 Compression + +compression은 provider profile이다. + +- first profile은 `compression.type=none`으로 고정한다; +- 선택 시 broker/client version 지원과 CPU/memory를 test한다; +- decompression bomb 방어를 위해 consumer는 decoded envelope/payload bound를 별도로 검증한다; +- record limit은 wire/uncompressed 의미를 혼동하지 않는다. + +### 17.8 Admission과 backpressure + +producer 내부 buffer만을 application bulkhead로 사용하지 않는다. + +```text +application admission semaphore +-> per-record JIT claim +-> Kafka producer buffer +-> broker +``` + +- admission wait는 attempt deadline에 포함한다; +- queue는 finite이며 queue timeout을 가진다; +- queue saturation 때 더 많은 outbox row를 claim하지 않는다; +- virtual thread를 사용해도 in-flight/message/memory bound는 유지한다; +- initial polling R2는 per-record JIT claim과 bounded sequential send를 사용한다. 성능 evidence가 + 필요할 때만 partition-key-aware concurrency card를 추가한다. + +### 17.9 Graceful shutdown + +순서: + +```text +1. readiness에서 신규 relay admission 제거 +2. scheduler/new claim 중단 +3. active attempt를 bounded drain +4. 완료 ACK의 delivery transition을 bounded flush +5. unresolved attempt를 indeterminate로 남기거나 lease reclaim 가능하게 종료 +6. KafkaTemplate/ProducerFactory/AdminClient close +7. metrics/secret refresh resource close +``` + +shutdown timeout이 끝났다고 delivery row를 성공 처리하지 않는다. unresolved row는 lease expiry 뒤 +재claim되며 duplicate 가능성이 있다. + +### 17.10 Startup + +순서: + +```text +1. typed settings bind +2. card/catalog/schema hash compile +3. secret resolve +4. producer runtime create +5. topic/security attestation +6. readiness ACTIVE +7. polling scheduler admission +``` + +relay scheduler를 producer/topic readiness보다 먼저 시작하지 않는다. + +## 18. Polling outbox v2 + +### 18.1 Immutable event + +목표 `outbox_event` conceptual columns: + +```text +event_id VARCHAR(96) PK, canonical US-ASCII +envelope_version +contract_id +payload_version +logical_destination_id +destination_binding_revision +aggregate_type +aggregate_id +aggregate_sequence +aggregate_event_index +partition_key_text VARCHAR(64), canonical lowercase SHA-256 hex +occurred_at +created_at +tenant_scope NOT NULL canonical scope +correlation_id +causation_id nullable +traceparent nullable, validated +tracestate nullable, validated +content_type +envelope_bytes BYTEA, exact UTF-8 wire document +envelope_sha256 +envelope_schema_hash +payload_schema_hash +schema_set_hash +contract_catalog_revision +publication_epoch +dispatch_authority VARCHAR + CHECK: LEGACY_POLLING | POLLING_V2 | CDC +transaction_resource_id +``` + +규칙: + +- INSERT-only after migration cutover; +- identity/order unique constraint; +- event bytes/metadata immutable; +- `JSONB`나 재직렬화 가능한 `TEXT`를 wire authority로 사용하지 않음; +- `BYTEA`와 hash가 polling/CDC의 exact byte authority; +- polling status/attempt/owner 없음; +- CDC source predicate가 이 table의 INSERT만 받음; +- delete는 retention maintenance뿐이며 connector behavior를 test함. + +### 18.2 Delivery control + +목표 `outbox_delivery` conceptual columns: + +```text +event_id FK +delivery_generation +authority_status CURRENT | SUPERSEDED +superseded_by_generation nullable +dispatch_profile_id +destination_binding_revision +state +claim_count +publication_attempt_count +first_attempt_at +next_attempt_at +claim_token +claim_owner +claim_until +last_outcome_certainty +last_failure_class +last_failure_stage +provider_generation +provider_record_reference +delivery_recorded_at +terminal_at +row_version +created_at delivery generation DB creation time +automatic_attempt_deadline DB time, immutable per generation +updated_at +``` + +primary identity: + +```text +(event_id, delivery_generation) +``` + +`UNIQUE(event_id) WHERE authority_status='CURRENT'`로 event당 authoritative delivery +generation을 정확히 하나만 허용한다. requeue transaction은 current row를 lock하고 audit를 +append한 뒤 기존 row를 `SUPERSEDED`로 바꾸고 `delivery_generation + 1`, `CURRENT`, `READY` row를 +삽입한다. 새 row의 `created_at`과 `automatic_attempt_deadline`은 같은 DB transaction에서 +§18.9대로 계산한다. `superseded_by_generation`은 새 generation을 가리킨다. update와 insert 중 +하나라도 실패하면 transaction 전체가 rollback한다. + +first baseline은 event 하나에 logical destination 하나다. multi-destination fan-out card를 나중에 +추가하면 destination을 delivery identity와 current-authority constraint에 포함하고 한 destination +성공이 다른 destination을 완료시키지 않도록 별도 설계한다. + +### 18.3 Append-only attempt journal + +`outbox_delivery_attempt_observation`은 delivery control과 별도인 append-only audit다. + +```text +event_id +delivery_generation +publication_attempt_id +observation_sequence +observation_type ATTEMPT_ADMITTED | OUTCOME_OBSERVED | LATE_ACK_OBSERVED +claim_token_digest +producer_generation +destination_binding_revision +observed_at +acceptance_certainty +retry_disposition +failure_class +failure_stage +provider_record_reference +``` + +흐름: + +1. local admission permit를 먼저 확보한다; +2. 한 짧은 claim transaction에서 한 row만 claim하고 valid remaining lease를 확인한 뒤 + `claim_count + 1`, `publication_attempt_id`, `publication_attempt_count + 1`, + `ATTEMPT_ADMITTED`를 함께 기록한다; +3. 이 durable marker 이후에만 Kafka send를 호출한다; +4. marker 뒤 process crash는 실제 send 전이어도 안전하게 `INDETERMINATE`로 복구한다; +5. provider outcome observation과 Tx C state transition은 같은 short transaction에서 기록한다; +6. deadline 뒤 ACK는 §15.2의 bounded source/drain 경계를 통해 성공적으로 persisted된 경우에만 + `LATE_ACK_OBSERVED`를 append하고 delivery state를 뒤집지 않는다. + +raw claim token은 journal/log/metric에 복제하지 않는다. attempt observation retention은 operator +reconciliation과 delivery retention보다 짧을 수 없다. +`LATE_ACK_OBSERVED`는 성공적으로 capture됐을 때 durable한 진단 사실이지만 late callback capture +자체는 crash-proof하지 않다. admission/outcome journal만 publication state machine의 필수 +evidence다. +first profile에는 claim만 commit하고 나중에 queue에서 send하는 중간 상태가 없다. lease reclaim +수와 실제 publication admission 수는 별도 counter로 관측한다. + +### 18.4 State + +target polling state: + +```text +READY +CLAIMED +RETRY_WAIT +DELIVERY_RECORDED +EXHAUSTED +HOLD +SKIPPED +COMPENSATED +LEGACY_RECORDED_UNVERIFIED +LEGACY_ACCEPTED_UNVERIFIED +``` + +`EXHAUSTED`는 “broker에 절대 전달되지 않았다”는 뜻이 아니다. 정해진 attempt/elapsed budget 안에 +delivery recording을 완료하지 못해 자동 처리를 중단했다는 뜻이다. 마지막 certainty가 +`INDETERMINATE`이면 이미 전달되었을 수 있다. + +`EXHAUSTED`와 `HOLD`는 automation-terminal이지만 ordering/retention 관점에서는 unresolved다. +`HOLD`는 ACK destination mismatch, invariant violation 또는 audited operator pause 때문에 +자동 재시도를 허용하지 않는 상태다. +`LEGACY_RECORDED_UNVERIFIED`는 current `void KafkaSender` normal return을 보존하는 migration-only +상태이며 broker ACK, offset, `delivery_recorded_at`을 채우지 않는다. downstream reconciliation과 +operator approval 뒤 `LEGACY_ACCEPTED_UNVERIFIED`로만 전이할 수 있고 이 상태도 broker ACK를 +뜻하지 않는다. + +consumer-side Kafka DLT와 producer-side `EXHAUSTED`를 둘 다 “dead letter”라고 부르지 않는다. +legacy `DEAD/OUTBOX_DEAD_LETTER` vocabulary는 migration alias로만 유지하고 runbook을 분리한다. + +### 18.5 State machine + +```mermaid +stateDiagram-v2 + [*] --> READY + READY --> CLAIMED: claim(token, lease) + RETRY_WAIT --> CLAIMED: due + claim(token, lease) + CLAIMED --> DELIVERY_RECORDED: broker ACK + valid-lease token CAS + CLAIMED --> RETRY_WAIT: retryable/indeterminate + token CAS + CLAIMED --> EXHAUSTED: permanent/budget exhausted + token CAS + CLAIMED --> HOLD: ACK mismatch/invariant + token CAS + CLAIMED --> CLAIMED: lease expired + new token reclaim + EXHAUSTED --> NEW_READY: audited supersede + new generation row + HOLD --> NEW_READY: audited supersede + new generation row + EXHAUSTED --> SKIPPED: audited disposition + EXHAUSTED --> COMPENSATED: audited disposition + HOLD --> SKIPPED: audited disposition + HOLD --> COMPENSATED: audited disposition + LEGACY_RECORDED_UNVERIFIED --> LEGACY_ACCEPTED_UNVERIFIED: reconciliation + approval + state "READY (generation + 1)" as NEW_READY + DELIVERY_RECORDED --> [*] + SKIPPED --> [*] + COMPENSATED --> [*] + LEGACY_ACCEPTED_UNVERIFIED --> [*] +``` + +실제로 EXHAUSTED/HOLD row를 READY로 UPDATE하지 않는다. operator requeue는 같은 immutable +event를 참조하는 `deliveryGeneration + 1` current row와 audit record를 만들고 이전 row를 +`SUPERSEDED` authority로 바꾸는 한 transaction이다. 상태 diagram의 `NEW_READY` 화살표는 이전 +row의 state overwrite가 아니라 이 authority handoff를 뜻한다. + +### 18.6 Claim token와 valid-lease CAS + +active worker가 소유한 renew와 outcome transition은 다음 조건을 가진다. + +```text +WHERE event_id = ? + AND delivery_generation = ? + AND authority_status = 'CURRENT' + AND state = 'CLAIMED' + AND claim_token = ? + AND claim_owner = ? + AND claim_until > database_now +``` + +affected row가 정확히 1이 아니면 stale-owner conflict다. stale worker는 broker ACK를 늦게 받아도 +새 owner의 state를 `DELIVERY_RECORDED/RETRY_WAIT/EXHAUSTED`로 덮지 못한다. 새 worker가 아직 +reclaim하지 않았더라도 lease가 만료된 old owner는 terminal state를 기록할 수 없다. + +claim token은 추측 불가능한 opaque value이고 metric tag가 아니다. `row_version`은 JPA optimistic +locking 보조 수단일 뿐 claim token을 대체하지 않는다. worker renew와 worker-owned outcome +transition은 DB time으로 valid lease를 검사한다. + +다른 mutation은 active-worker predicate를 흉내 내지 않고 각자 다음 fence를 사용한다. + +| Mutation | Required predicate/fence | +| --- | --- | +| initial claim | `CURRENT` + `READY` 또는 due `RETRY_WAIT` + ordering eligibility + expected `row_version`; row lock 안에서 새 token/owner/DB-time lease 설정 | +| expired reclaim | `CURRENT` + `CLAIMED` + `claim_until <= database_now` + expected `row_version`; 이전 token을 새 opaque token으로 교체 | +| worker renew/outcome | 위의 current token/owner + `claim_until > database_now` predicate | +| operator HOLD/SKIP/COMPENSATE/legacy accept | `CURRENT` + expected generation/state/row_version + active unexpired claim 없음 + authorization/audit record in same transaction | +| requeue | current row lock + expected generation/state/row_version + active unexpired claim 없음; old authority `SUPERSEDED`와 new `CURRENT/READY` insert를 same transaction | + +각 update의 affected row는 정확히 1이어야 한다. operator가 live worker의 token을 무시하고 raw +status를 덮어쓰지 않는다. 긴급 HOLD가 필요하면 신규 claim을 먼저 fence하고 active worker +drain 또는 lease expiry 뒤 operator CAS를 수행한다. + +### 18.7 Claim eligibility + +후보: + +- READY; +- `RETRY_WAIT AND next_attempt_at <= database_now`; +- `CLAIMED AND claim_until <= database_now`. + +ordering-required scope에서는 더 작은 aggregate order event의 +`authority_status=CURRENT` generation이 +`DELIVERY_RECORDED`, audited `SKIPPED/COMPENSATED` 또는 audited +`LEGACY_ACCEPTED_UNVERIFIED`가 아니면 claim하지 않는다. +`EXHAUSTED/HOLD/LEGACY_RECORDED_UNVERIFIED`를 단순 terminal로 보고 통과시키지 않는다. query는 +stable total order와 `FOR UPDATE SKIP LOCKED`를 사용한다. + +### 18.8 First claim/lease strategy + +first implementation은 `postgresql-per-record-jit-claim.v1` 하나로 고정한다. + +```text +local admission permit reserve +-> one eligible row JIT claim +-> valid remaining lease check +-> attempt admission journal +-> one send/observe/Tx C +-> permit release +``` + +- publish 시작 전 remaining lease가 attempt budget보다 작으면 send하지 않는다; +- renew도 claim token CAS다; +- lease expiry 뒤 late sender는 authoritative state를 바꾸지 못한다; +- duplicate publication 가능성은 남으므로 consumer inbox가 필요하다. + +```text +claimLease > + admission-after-claim reserve + + max.block.ms + + delivery.timeout.ms + + callback/TxC reserve + + scheduling safety margin +``` + +batch/window와 partition-key-aware concurrent claim은 throughput evidence가 필요할 때 별도 profile로 +추가한다. + +### 18.9 Retry time + +retry due와 lease는 database time을 사용한다. backoff는 bounded exponential + jitter를 사용할 수 +있지만 다음을 descriptor에 고정한다. + +- claim count와 publication attempt count; +- delivery-generation DB-created-at 기준 maximum automatic publication age; +- minimum/maximum delay; +- jitter source/range; +- failure-class override; +- operator hold; +- destination backlog capacity. + +현재 fixed `maxAttempts=3`을 영구 정본으로 보지 않는다. real fault/capacity evidence로 first R2 +profile 값을 고정한다. + +initial generation은 DB-authoritative `outbox_event.created_at`에서 automatic publication age를 +시작한다. audited requeue가 만든 새 generation은 그 delivery row의 DB `created_at`에서 새롭지만 +여전히 finite한 한-generation attempt budget을 시작하되, 원본 event의 same-ID requeue horizon을 +넘지 못한다. + +```text +initial generation: + automaticAttemptDeadline = + min( + outbox_event.created_at + profile.maximumAutomaticPublicationAge, + outbox_event.created_at + contract.sameEventRequeueHorizon + ) + +requeue generation: + automaticAttemptDeadline = + min( + outbox_delivery.created_at + profile.maximumAutomaticPublicationAge, + outbox_event.created_at + contract.sameEventRequeueHorizon + ) +``` + +계산 결과를 `outbox_delivery.automatic_attempt_deadline`에 immutable하게 저장해 profile reload로 +기존 generation의 deadline이 움직이지 않게 한다. claim 시 database time이 deadline 이상이거나 +한 full attempt budget이 남지 않으면, 첫 시도 전 backlog row라도 send하지 않고 `EXHAUSTED`로 +fenced transition한다. `first_attempt_at`은 관측값일 뿐 budget을 새로 시작하지 않는다. + +따라서 원본 event의 initial generation이 age로 EXHAUSTED된 뒤라도 requeue horizon 안에서 승인된 +operator requeue는 새 generation에 한 번의 bounded automatic window를 부여한다. 그 window도 +`requeueDeadline`에서 잘리며 horizon 뒤 same-ID resend는 여전히 금지한다. 오래된 event를 장애 +복구 직후 발행해야 하면 이 audited requeue 또는 새 compensation/corrected event를 선택한다. + +### 18.10 Leader election + +PostgreSQL row claim과 token CAS가 correctness를 제공한다. single leader는 scheduler +amplification을 줄이는 efficiency option일 수 있지만 correctness의 유일한 근거가 아니다. + +현재 `OutboxLeaderElectionToken` marker와 “leader election” test 이름이 실제 consensus leader를 +증명한다고 표현하지 않는다. 여러 instance가 claim에 참여하는 profile이라면 +`multi-worker-row-partitioning`처럼 정확히 이름 붙인다. + +## 19. Polling transaction, crash, disposition과 retention + +### 19.1 Crash matrix + +| Crash/failure point | Persisted state | Broker 가능성 | Recovery | +| --- | --- | --- | --- | +| business write 전 | 없음 | 없음 | caller retry | +| business write 후 outbox append 전, same tx rollback | 없음 | 없음 | caller retry | +| event/delivery commit 후 claim 전 | READY | 없음 | normal claim | +| claim commit 후 send 전 crash | CLAIMED | 없음 | lease expiry/reclaim | +| send request 뒤 ACK 전 connection loss | CLAIMED | accepted 가능 | indeterminate + reclaim | +| broker ACK 뒤 delivery CAS 전 crash | CLAIMED | accepted | reclaim, duplicate 가능 | +| ACK 뒤 stale token | 새 owner state | accepted | late owner state mutation 거부 | +| definite transient rejection | RETRY_WAIT | 미수락 확정 | due retry | +| permanent definite rejection | EXHAUSTED | 미수락 확정 | operator remediation | +| DELIVERY_RECORDED commit 뒤 process crash | DELIVERY_RECORDED | accepted | no automatic re-send | +| shutdown timeout 중 unresolved send | CLAIMED | accepted 가능 | lease reclaim, duplicate 가능 | + +이 표는 “중복 없음”이 아니라 중복 발생 지점과 authoritative recovery를 고정한다. + +### 19.2 Transaction boundaries + +```text +Tx A: business write + outbox_event + outbox_delivery +Tx B: one-row JIT claim + valid lease + publication attempt admission observation +No DB Tx: broker publish/ACK wait +Tx C: outcome observation + valid-lease token-CAS DELIVERY_RECORDED/RETRY_WAIT/EXHAUSTED +``` + +broker call을 Tx B/C 안에 넣어 DB connection/row lock을 ACK timeout 동안 잡지 않는다. +first relay command invocation은 최대 한 record만 처리해 per-record `REQUIRES_NEW` loop를 만들지 +않는다. scheduler가 bounded rate로 다음 invocation을 요청하고, 각 invocation은 Tx B와 Tx C를 +순차로 열되 capacity relation을 test한다. + +### 19.3 Append disabled/misconfigured + +R2 deployment에서 active durable contract가 하나라도 있으면 dispatch는 `polling` 또는 `cdc`여야 +한다. + +- `polling`인데 ACK-aware producer/topic binding이 없으면 startup fail; +- `cdc`인데 external connector expected-state/evidence가 없으면 deployment gate fail; +- `disabled`인데 durable contract binding이 있으면 startup fail; +- empty contract catalog + disabled는 resource 0. + +현재 `.env`처럼 relay enabled + broker blank로 모든 row를 DEAD에 보내는 조합은 target에서 +허용하지 않는다. + +### 19.4 Exhausted head disposition + +strict ordered aggregate head가 EXHAUSTED이면 operator는 다음 중 하나를 선택한다. + +- REMEDIATE_AND_REQUEUE: 원인 수정 뒤 새 delivery generation; +- SKIP_WITH_GAP: business owner 승인과 reason/audit 뒤 후행 release; +- HOLD: 후행 계속 차단; +- COMPENSATE: 별도 compensating integration event. + +raw SQL로 `status=PUBLISHED`를 설정해 skip을 숨기지 않는다. 모든 disposition은: + +```text +operator identity +authorization +reason code/text bound +incident/change reference +old/new generation +payload/schema hash +affected ordering scope +timestamp +approval when destructive +``` + +를 immutable audit로 남긴다. + +첫 R2의 operator control surface는 기존 `adapter:inbound:web` leaf의 인증된 internal HTTP +endpoint 하나로 고정한다. + +```text +POST /internal/operations/messaging/outbox/{eventId}/dispositions +permission: outbox:disposition +destructive permission for SKIP/COMPENSATE: outbox:disposition:destructive +required: Idempotency-Key, expected deliveryGeneration, expected rowVersion, + disposition, bounded reason, incident/change reference +``` + +- web request/auth principal은 inbound DTO에서 application의 + `ApplyOutboxDispositionCommand`로 mapping하고 application에 web/security 타입을 넘기지 않는다; +- application-core는 `ApplyOutboxDispositionUseCase`와 + `OutboxDispositionPort`를 소유한다; +- use case는 existing framework-free `@RequiresPermission("outbox:disposition")` contract를 + 사용하고 destructive operation은 `AuthorizationPort`로 추가 permission을 검증한다; +- use case가 permission, allowed source state, requeue horizon, ordering impact, + destructive approval reference와 compensation event reference를 검증한다; +- persistence adapter는 §18.6 operator CAS, authority handoff와 immutable audit를 한 transaction에 + 구현한다; +- REQUEUE는 old authority supersede + new generation insert, HOLD/SKIP/COMPENSATE는 expected + current row transition이다; +- controller가 repository/entity를 직접 호출하거나 app-bootstrap이 policy를 구현하지 않는다; +- endpoint는 management/public business API와 구분한 internal network policy, strong + authentication, rate bound와 audit를 요구하고 OpenAPI/public-path/security snapshot test에 + 포함한다; +- raw SQL과 writable Actuator endpoint는 대체 control surface가 아니다. + +`COMPENSATED`는 “보상할 예정”이 아니다. feature owner가 만든 immutable compensating event +reference가 같은 transaction에서 검증·audit된 뒤에만 기록한다. `SKIP`과 `COMPENSATE`는 +destructive permission과 승인 reference 없이는 실패한다. + +### 19.5 Replay/requeue + +producer-side requeue는 같은 event ID와 document를 새 delivery generation으로 다시 publish한다. +한 transaction에서 기존 current generation의 authority를 `SUPERSEDED`로 넘기고 새 +`CURRENT/READY` generation을 만든다. 새 business event를 만들지 않는다. 이미 consumer effect가 +적용되었을 수 있으므로 duplicate를 전제로 한다. + +same-event requeue는 무기한 허용하지 않는다. + +```text +requeueDeadline = + outbox_event.created_at(DB time) + contract.sameEventRequeueHorizon +``` + +- horizon은 finite이고 contract/catalog hash에 포함한다; +- required consumer가 존재하면 horizon은 모든 required consumer의 inbox/dedupe archive coverage + 중 최솟값 이하여야 한다; +- producer-only first R2는 end-to-end duplicate absorption을 주장하지 않더라도 deadline을 + enforce하고 operator에게 downstream dedupe 확인 책임을 노출한다; +- deadline 이후 같은 event ID generation 생성은 fail-closed다; +- 오래된 EXHAUSTED/HOLD row는 audit/retention 때문에 남을 수 있지만 same-ID resend 대상은 + 아니다. business owner는 `SKIP`, 검증된 새 compensation/corrected event 또는 별도 durable + dedupe-archive card를 선택한다. + +payload를 수정해야 하면 기존 event를 바꾸지 않고 새 event ID/contract version을 가진 corrected +또는 compensating event를 만든다. + +### 19.6 Retention + +polling retention 조건: + +```text +the unique CURRENT authoritative generation resolved as + DELIVERY_RECORDED or audited SKIPPED/COMPENSATED/LEGACY_ACCEPTED_UNVERIFIED +AND no active claim/requeue +AND publication audit retention elapsed +AND operator/legal hold 없음 +AND configured replay horizon elapsed +``` + +삭제 순서는 delivery/audit FK와 partition strategy가 결정한다. cascade가 audit를 조용히 +없애지 않도록 test한다. reaper는 claim/requeue와 CAS로 경합하고 event를 먼저 지우지 않는다. +CURRENT generation이 `EXHAUSTED`, `HOLD`, `LEGACY_RECORDED_UNVERIFIED`이거나 unresolved attempt +observation이 있으면 automation-terminal이어도 삭제하지 않는다. superseded generation과 그 +authority-handoff audit도 current generation의 전체 retention 조건이 충족되기 전에 따로 +삭제하지 않는다. + +Kafka topic retention이 consumer replay source라 해도 outbox event retention과 동일한 기간이라고 +가정하지 않는다. + +### 19.7 Partitioning + +`outbox_event`는 occurred/created time 기준 range partition을 사용할 수 있다. 하지만 strict +aggregate ordering query, active delivery FK와 cleanup을 함께 benchmark한다. + +closed partition 삭제는: + +- polling에서는 terminal/replay 조건; +- CDC에서는 connector checkpoint proof + +가 다르다. polling `DELIVERY_RECORDED` status를 CDC cleanup proof로 재사용하지 않는다. + +### 19.8 Backlog capacity + +durable API는 broker outage 중 DB에 event를 안전하게 쌓을 수 있으므로 Kafka 순간 장애만으로 +모든 write endpoint readiness를 즉시 내릴 필요는 없다. + +대신 다음을 구분한다. + +- relay readiness: producer/topic에 의존; +- write admission readiness: DB free space, oldest age, backlog count/growth, retention/SLO; +- direct required producer readiness: Kafka에 직접 의존; +- liveness: 외부 dependency와 무관. + +backlog capacity/SLO threshold를 넘으면 새 durable writes를 받을지 degrade할지는 deployment +policy로 명시한다. + +## 20. Best-effort publication + +### 20.1 정확한 의미 + +best-effort는 다음만 보장한다. + +```text +closed contract validation ++ bounded local/producer attempt ++ outcome observation +- durable persistence +- automatic replay +- business transaction atomicity +``` + +first provider는 같은 ACK-aware Kafka gateway를 사용할 수 있다. failure를 caller에게 전파하지 +않더라도 metric/log에는 +ACKNOWLEDGED/ACKNOWLEDGED_MISMATCH/REJECTED/INDETERMINATE를 정확히 기록한다. + +### 20.2 Naming + +`MessagePublisher`처럼 durability가 모호한 이름은 migration 동안 유지할 수 있으나 target +application-facing 이름은 `BestEffort...`를 포함한다. durable event는 `Outbox...` contract를 +사용한다. + +### 20.3 Failure policy + +- non-critical telemetry-like side effect만 fail-open을 선택한다; +- failure를 삼킨다고 outbox가 자동으로 대신하지 않는다; +- caller가 같은 semantic event를 best-effort와 outbox로 동시에 보내지 않는다; +- disabled best-effort binding은 호출 시 fail-fast하고 silent no-op이 아니다; +- business correctness가 delivery에 의존하면 best-effort를 선택할 수 없다. + +### 20.4 Async optional card + +caller latency를 위해 local enqueue 뒤 즉시 반환하는 truly asynchronous best-effort card를 +나중에 추가할 수 있다. 그 card는 결과를 `ENQUEUED`로만 표현하고 broker ACK/durability를 +주장하지 않는다. bounded queue, drop policy, shutdown drain과 loss metric을 별도 evidence로 +가져야 한다. + +## 21. Activation, configuration과 expected state + +### 21.1 Canonical target shape + +다음은 설계 목표 shape이며 현재 `application.yml`에 그대로 추가하라는 뜻이 아니다. first R2 +구현이 존재할 때 구현된 필드만 live configuration으로 추가한다. + +```yaml +app: + messaging: + expected-state: ACTIVE + + publication: + producer-provider: kafka-spring + producer-profile: acknowledged-idempotent-v1 + serialization-profile: json-schema-envelope-v1 + topic-profile: externally-provisioned-and-validated-v1 + ordering-profile: per-key-normal-path-sequence-detectable-v1 + compression-profile: none-v1 + + outbox: + dispatch-mode: polling + polling-profile: postgresql-polling-v2 + claim-profile: postgresql-per-record-jit-claim-v1 + transaction-resource-id: primary-jpa + operator-control-profile: authenticated-internal-web-disposition-v1 + claim-lease: 90s + maximum-relay-attempts: 5 + maximum-automatic-publication-age: 15m + same-event-requeue-horizon: 7d + + kafka: + cluster-id: primary + bootstrap-servers: + - kafka-1.example.internal:9093 + - kafka-2.example.internal:9093 + security-profile: kafka-sasl-ssl-scram-sha-512-v1 + secret-reference: secret://messaging/kafka/producer + producer: + admission-timeout: 1s + delivery-timeout: 60s + request-timeout: 35s + max-block-timeout: 5s + application-attempt-budget: 68s + buffer-memory-bytes: 33554432 + maximum-request-bytes: 4194304 + maximum-admitted-records: 1 + + destinations: + portfolio-domain-events: + binding-revision: portfolio-domain-events-r1 + topic: portfolio.domain-events.v1 + expected-partitions: 12 + minimum-replication-factor: 3 + minimum-in-sync-replicas: 2 + maximum-record-bytes: 1048576 + maximum-envelope-bytes: 786432 + required: true +``` + +숫자는 설명을 위한 candidate다. implementation plan에서 adopted Kafka/Broker version, +Testcontainers/fault/capacity evidence로 기본값과 상한을 고정한다. candidate도 protocol/header +headroom과 §16.6 budget 관계를 만족하도록 서로 같은 1 MiB 값을 복제하지 않는다. + +### 21.2 Expected state + +```text +DISABLED +ACTIVE +``` + +`DISABLED`: + +- active contract/destination 0; +- producer factory/template/AdminClient 0; +- polling scheduler 0; +- listener container 0; +- secret refresh 0; +- network connection 0. + +`ACTIVE`: + +- exact selected tuple가 모두 known/release-eligible; +- active contract가 모두 compiled; +- required security material이 resolved; +- runtime state는 `STARTING | ACTIVE_NOT_READY | ACTIVE_READY`; +- required destination/security/topology attestation이 fresh할 때만 `ACTIVE_READY`. + +`enabled=true`와 provider 이름을 여러 곳에서 조합하지 않는다. + +static schema/card/security/deadline conflict는 resource 생성 전 startup failure다. exact tuple은 +유효하지만 broker/topic이 일시적으로 unavailable한 경우 first profile은 context를 +`ACTIVE_NOT_READY`로 시작하고 scheduler admission을 막은 채 bounded backoff로 재-attest한다. +credential 누락, plaintext downgrade, unknown topic binding처럼 static/authorization failure를 +transient로 숨기지 않는다. `QUALIFICATION_ONLY`는 test harness mode이지 deployment expected +state가 아니다. + +### 21.3 Cross-field validation + +최소 startup failure: + +- ACTIVE + provider blank/unknown; +- ACTIVE + empty bootstrap server; +- ACTIVE + empty contract/destination; +- ACTIVE + unqualified card; +- polling + ACK-aware producer 없음; +- polling + delivery schema/claim settings 없음; +- polling + claim profile가 per-record JIT가 아님; +- polling + authenticated operator disposition control 없음; +- business/outbox transaction resource identity 불일치; +- CDC + polling scheduler active; +- DB publication epoch와 expected authority 불일치; +- disabled + active durable contract; +- production + plaintext; +- production + literal credential; +- idempotence와 충돌하는 `acks/retries/max.in.flight`; +- delivery/request/linger deadline 관계 위반; +- attempt budget과 claim lease 관계 위반; +- automatic publication/requeue/inbox dedupe horizon 관계 위반; +- contract bytes > destination/provider/topic bound; +- ordering-required + null key; +- duplicate topic/binding/schema ID; +- schema/catalog/evidence hash mismatch; +- legacy와 target activation이 동시에 설정됨. + +### 21.4 Typed settings + +raw `Map kafkaProperties`를 R2 public config로 노출하지 않는다. first profile이 +실제로 support하는 setting만 typed field로 제공한다. + +Kafka client upgrade로 새 setting이 필요하면: + +1. threat/guarantee 영향 검토; +2. typed setting/validation; +3. effective config assertion; +4. fault/security/compatibility test; +5. card version 또는 evidence fingerprint update + +를 함께 수행한다. + +### 21.5 Legacy migration + +현재: + +```text +APP_MESSAGING_BROKER +APP_MESSAGING_KAFKA_BROKERS +ca-skeleton.outbox.relay-enabled +``` + +목표 migration: + +- legacy 값은 R0 `external-kafka-sender-legacy.v1` descriptor와 endpoint seed로만 해석한다; +- target R2 exact tuple은 새 contract/destination/security/dispatch 설정을 모두 명시해야 한다; +- target key와 legacy key가 동시에 존재하면 fail; +- `broker=kafka`가 `kafka-spring` R2를 자동 의미하지 않는다; +- `relay-enabled=true/false`는 `dispatch-mode`로 대체한다; +- warning과 removal release를 명시한다; +- legacy seam 사용은 descriptor에 R0로 노출한다; +- runbook/.env/README/env registry를 같은 변경에서 갱신한다. + +조용한 precedence는 없다. + +### 21.6 Environment registry + +새 environment key는 `docs/registries/env-keys.yaml`에: + +- owner; +- type/default/allowed values; +- secret classification; +- validation; +- compatibility impact; +- required test + +를 등록한다. + +logical destination/topic key는 fork가 실제 contract를 추가할 때 등록한다. skeleton은 존재하지 +않는 sample production destination을 global env registry에 강제로 추가하지 않는다. + +### 21.7 Secret reference + +configuration에는 secret value가 아니라 reference만 둔다. + +```text +secret://messaging/kafka/producer +``` + +secret resolver가 반환하는 material은: + +- char/byte lifecycle을 제한; +- log/toString/config dump에서 redact; +- generation과 expiry만 sanitized descriptor에 노출; +- rotation 실패 시 old generation 사용 가능 기간을 bounded policy로 관리한다. + +### 21.8 Compiled runtime descriptor + +startup 뒤 sanitized endpoint는 다음을 보여준다. + +```text +expectedState +runtimeState +semantic/provider/dispatch/serialization/topic/security card IDs +provider/client versions +contract catalog hash +schema set hash +destination IDs +destination binding revisions +transaction resource ID +publication epoch/dispatch authority +settings digest +producer generation +readiness level +evidence fingerprint/status +explicit non-guarantees +runbook IDs +``` + +bootstrap server, topic이 민감한 deployment에서는 hash/alias만 노출한다. credential, raw headers, +payload는 절대 노출하지 않는다. + +### 21.9 Future consumer/CDC settings + +consumer와 CDC는 §24–§27의 contract가 구현될 때만 typed setting을 추가한다. 지금 live YAML에: + +```text +consumer.enabled +inbox.provider +cdc.enabled +schemaRegistry.url +``` + +같은 미구현 switch를 먼저 만들지 않는다. + +## 22. Security와 topic governance + +### 22.1 Threat model + +| Threat | Control | +| --- | --- | +| arbitrary topic publish | closed destination binding | +| broker MITM | TLS hostname verification + trusted CA | +| credential leak | secret reference, redaction, generation rotation | +| over-privileged principal | destination별 least-privilege ACL | +| plaintext downgrade | production startup fail | +| payload/header injection | schema/header allowlist + byte bounds | +| cross-tenant leak | tenant-aware contract/key/auth, no dynamic topic | +| replay abuse | audited replay authorization/rate bound | +| poison/oversized record | writer validation + consumer hardening | +| dependency compromise | lock/SBOM/signature/vulnerability gate | +| topic policy drift | startup/pre-deploy attestation | +| DLT sensitive-data accumulation | restricted ACL, retention, redaction policy | + +### 22.2 Network profile + +허용 profile: + +```text +local-plaintext-v1 local/dev only +tls-server-auth-v1 controlled non-production or explicit policy +sasl-ssl-scram-v1 production candidate +sasl-ssl-oauth-v1 future/qualified candidate +mtls-v1 deployment requirement가 있을 때 +``` + +production은 `SSL` 또는 `SASL_SSL`만 허용한다. PLAIN/SCRAM credential을 TLS 없이 사용하지 +않는다. custom trust-all, hostname verification disable, insecure callback handler를 금지한다. + +first production reference는 `SASL_SSL + SCRAM-SHA-512`로 고정한다. OAuth, mTLS와 +server-auth-only TLS는 future profile이며 first tuple의 보장을 자동 상속하지 않는다. + +### 22.3 TLS + +- endpoint hostname verification 활성; +- protocol/cipher allowlist는 platform security policy와 정렬; +- truststore/keystore location과 password를 secret material로 취급; +- certificate expiry/chain/hostname negative test; +- rotation generation swap; +- emergency revocation runbook; +- clock skew와 certificate validity 관측; +- local self-signed CA는 explicit dev test profile에만 허용. + +### 22.4 SASL + +mechanism은 typed allowlist다. JAAS literal string을 일반 application YAML/log에 노출하지 않는다. + +- SCRAM: username/password secret generation과 broker-side iteration/security 정책; +- OAUTHBEARER: issuer/audience/token endpoint TLS, token refresh deadline, secret/key rotation; +- GSSAPI: 실제 platform 요구와 qualification이 있을 때만; +- PLAIN: SASL_SSL에서만 explicit qualification. + +auth refresh thread/resource도 disabled profile에서 0이어야 한다. + +### 22.5 ACL + +producer principal의 최소 권한: + +```text +Describe on required cluster/topic scope +DescribeConfigs on exact production topics +Write on exact production topics +IdempotentWrite/transactional permissions only when adopted version/profile requires +``` + +기본 금지: + +```text +Create +Delete +Alter +Write to wildcard all topics +consumer Read +Connect internal topic access +``` + +AdminClient attestation 때문에 broker-wide config나 ACL enumeration 권한을 요구하지 않는다. +§13.5에서 runtime으로 확인할 수 없는 policy는 fresh signed/provenance-attested deployment +provisioning evidence로 보완하고 descriptor에 verification source를 기록한다. + +consumer, DLT publisher, Connect worker는 서로 다른 principal/ACL을 사용한다. + +### 22.6 Topic provisioning + +production topic은 infrastructure-as-code가 만든다. + +- topic name/config review; +- partition/RF/min ISR; +- retention/cleanup; +- max message bytes; +- quota; +- ACL; +- ownership/contact; +- change/rollback record. + +application startup은 validate하지 create/alter하지 않는다. + +### 22.7 Data classification + +contract descriptor는 payload sensitivity를 분류한다. + +- credential/token/password를 event payload로 보내지 않는다; +- 필요한 personal data만 최소화; +- tenant/user raw identity를 partition key로 쓸 때 bounded digest/pseudonymization 검토; +- topic/DLT/outbox/inbox retention과 data deletion 법적 요구를 맞춘다; +- encryption-at-rest는 broker/DB/platform control과 evidence로 관리; +- payload encryption field-level card는 key lifecycle과 consumer authorization을 함께 설계할 때만 + 추가한다. + +### 22.8 Header/tracing security + +- W3C `traceparent`/`tracestate` grammar와 size를 검증; +- baggage는 default propagation하지 않음; +- inbound credential/auth/cookie/header forwarding 금지; +- exception stack/Java class를 header에 넣지 않음; +- DLT header는 원본 allowlist + safe failure code만; +- repeated retry/DLT로 header가 무한 증식하지 않게 canonical rewrite. + +### 22.9 Tenant isolation + +tenant-aware deployment는 다음을 명시한다. + +- event에 tenant metadata가 필요한지; +- partition key에 tenant dimension 포함 여부; +- topic을 tenant별로 나눌지 shared로 둘지; +- producer/consumer ACL isolation; +- inbox unique scope; +- metric/log pseudonymization; +- replay authorization. + +request tenant input으로 topic을 동적 생성하지 않는다. tenant topic isolation은 finite +provisioned catalog로만 허용한다. + +## 23. Observability, health와 readiness + +### 23.1 관측 단위 + +다음을 분리한다. + +```text +business event append +polling claim +logical publication attempt +Kafka physical request/retry +broker acknowledgement +delivery state transition +consumer receive +application effect +offset commit +CDC source/connector checkpoint +``` + +한 `publish latency`에 queue/admission/broker/DB transition을 모두 합쳐 원인을 숨기지 않는다. + +### 23.2 Producer/outbox metrics + +최소 후보: + +```text +messaging.producer.attempts +messaging.producer.ack.latency +messaging.producer.queue.time +messaging.producer.outcome +messaging.producer.indeterminate +messaging.producer.buffer.available +messaging.producer.inflight +messaging.producer.throttle +messaging.producer.generation + +outbox.append.total +outbox.delivery.claim.total +outbox.delivery.claim.conflict +outbox.delivery.lease.expired +outbox.delivery.outcome +outbox.delivery.exhausted +outbox.backlog.count +outbox.backlog.oldest.age +outbox.ordering.blocked.count +outbox.replay.total +``` + +exact metric name은 metrics registry naming convention에 맞춰 구현 계획에서 확정한다. + +### 23.3 Metric tag + +허용 후보: + +```text +provider_id +logical_destination_id +contract_id catalog budget 안에서만 +outcome +certainty +failure_stage +failure_class +security_profile +dispatch_profile +``` + +금지: + +```text +event_id +aggregate_id +partition_key +tenant_id +user_id +correlation_id +physical offset +exception message +payload/schema hash +raw topic when not finite catalog +``` + +현재 `event_type cardinality_limit=50` 문서 값만 있고 runtime enforcement가 없는 상태를 +readiness evidence로 보지 않는다. compiled catalog cardinality와 global meter filter를 함께 +test한다. + +### 23.4 Tracing + +producer span: + +```text +logical publish span + -> Kafka client send observation + -> delivery-state DB span +``` + +consumer span: + +```text +Kafka receive/process span + -> application use-case span + -> inbox/business DB span + -> offset commit observation +``` + +Spring Kafka Micrometer Observation을 단일 instrumentation owner로 선택하고 manual trace header +writer와 중복하지 않는다. trace propagation은 W3C allowlist를 사용한다. payload, key, tenant, +event ID를 span attribute로 기본 기록하지 않는다. + +### 23.5 Logs + +정상 record마다 INFO log를 남기지 않는다. structured warning/error의 safe field: + +```text +error code/category +provider/logical destination/contract +outcome/certainty/failure stage +attempt count/delivery generation +opaque event ID와 correlation ID는 approved error log에서만 +runbook link +``` + +payload, raw key/header, credential, full broker config는 금지한다. exception cause는 logging +framework throwable로만 연결하고 message-derived arbitrary field를 만들지 않는다. + +확인된 persistence transition이 canonical ERROR 한 번을 소유한다. producer callback, relay, +report adapter가 같은 failure를 ERROR 세 번 남기지 않는다. + +### 23.6 Audit + +다음은 일반 log가 아니라 durable audit가 필요하다. + +- destination/topic binding 변경; +- capability/profile/security generation 변경; +- outbox requeue/skip/hold/compensate; +- consumer replay; +- group/consumer identity migration; +- CDC slot/offset reset; +- polling/CDC cutover/rollback; +- ACL/secret emergency action. + +### 23.7 Liveness + +Kafka/PostgreSQL/Connect outage가 JVM liveness를 내리지 않는다. liveness는 process/event-loop +deadlock 같은 내부 생존성만 본다. + +### 23.8 Startup/readiness + +role별 readiness: + +| Role | Readiness | +| --- | --- | +| direct required producer | provider/topic/security가 unavailable이면 DOWN | +| polling relay | producer + DB claim path + contract catalog | +| durable write API | DB/outbox append + backlog capacity; 순간 Kafka outage와 분리 가능 | +| optional best-effort producer | app readiness와 분리, descriptor DEGRADED | +| future required consumer | listener assignment/contract/inbox path | +| future CDC deployment | connector task/slot/offset/WAL/topic | + +continuous broker probe 하나로 모든 role을 동시에 DOWN시키지 않는다. + +### 23.9 Readiness hysteresis + +단일 transient timeout으로 readiness가 flap하지 않게: + +- startup hard failure와 runtime degradation을 구분; +- consecutive failure/success 또는 freshness window; +- last successful metadata/ACK/connector progress timestamp; +- backlog/SLO threshold; +- manual maintenance state; +- recovery proof + +를 descriptor에 둔다. 오래된 success를 영구 healthy로 사용하지 않는다. + +### 23.10 Alerts/dashboard + +최소 dashboard: + +- publish ACK/error/indeterminate rate와 latency; +- producer buffer/admission/throttle; +- outbox backlog/oldest age/state/lease conflict; +- destination/contract별 bounded view; +- consumer phase에는 lag/rebalance/retry/DLT/inbox duplicate; +- CDC phase에는 connector state/LSN lag/WAL retained bytes/offset progress/queue. + +alert는 runbook ID와 guarantee impact를 포함한다. stub runbook에 alert 이름만 있는 상태는 +operational evidence가 아니다. + +## 24. Future inbound Kafka consumer + +### 24.1 Phase와 module gate + +consumer는 first producer/polling R2와 별도 phase/card다. 구현 시작 전에: + +1. `modules.json`에 inbound Kafka leaf 추가; +2. settings include/mapping; +3. app-bootstrap allowed edge; +4. nearest `CLAUDE.md`; +5. architecture tests; +6. focused test path + +를 먼저 승인한다. + +기존 19-leaf topology는 producer/polling phase까지 유지하고 consumer phase에서 정확히 20개로 +registry migration한다. + +### 24.2 Baseline listener configuration + +첫 consumer card: + +```text +record listener +enable.auto.commit = false +AckMode = MANUAL_IMMEDIATE +asyncAcks = false +syncCommits = true +syncCommitTimeout = finite explicit value +max.poll.records = 1 +bounded concurrency +bounded fetch/message bytes +finite max.poll.interval.ms +finite session/heartbeat/request timeout +DefaultErrorHandler.ackAfterHandle = false +DefaultErrorHandler.commitRecovered = false +DefaultErrorHandler.resetStateOnRecoveryFailure = false +default logging/no-op recoverer = forbidden +``` + +batch listener는 unfinished record를 건너뛰는 offset high-water, partial failure와 memory bound를 +별도 증명하기 전에는 baseline이 아니다. + +first card는 handler, DB transaction, retry/DLT wait와 `Acknowledgment.acknowledge()`를 모두 +listener/consumer thread에서 동기 실행한다. off-thread worker가 ACK하지 않는다. +`MANUAL_IMMEDIATE`의 immediate 의미는 listener thread 호출과 explicit synchronous commit +profile에서만 주장한다. async handoff/batch는 별도 card다. + +expected decode/application outcomes는 listener가 typed result로 처리하고 §24.5의 explicit +ACK/DLT/HOLD 결정을 수행한다. container `DefaultErrorHandler`는 listener가 놓친 unexpected +exception의 seek/redelivery safety net일 뿐 disposition owner가 아니다. + +- `setAckAfterHandle(false)`와 `setCommitRecovered(false)`를 explicit effective assertion으로 + 고정한다; +- retry exhaustion 뒤 정상 반환하는 Spring default logging recoverer, + `CommonLoggingErrorHandler`와 no-op recoverer를 금지한다; +- unexpected exception의 bounded retry가 소진되면 custom terminal recoverer가 application + `HoldUnexpectedConsumerFailureUseCase`를 호출해 stable record identity, failed offset, + failure-class와 attempt evidence를 durable consumer HOLD로 기록한다. 성공하면 listener thread가 + failed offset으로 seek하고 해당 partition을 pause한 뒤 recoverer가 반환한다. assignment + callback은 §25.7과 같은 durable HOLD를 재적용하므로 restart/rebalance도 자동 재시작 경로가 + 아니다; +- durable HOLD 기록/seek/pause 중 하나라도 실패하면 recoverer는 예외를 던지고 별도 + `recoveryFailed` lifecycle listener가 container를 bounded stop하며 readiness를 DOWN으로 + 만든다. `resetStateOnRecoveryFailure=false`를 effective assertion으로 고정해 stop과 경합해도 + 전체 backoff cycle을 다시 시작하지 않는다. stop이 lifecycle deadline 안에 완료되지 않으면 + process liveness를 fail-closed하고 source offset은 commit하지 않는다; +- baseline은 key/value `byte[]` deserializer를 사용하므로 content decode failure는 listener 안의 + explicit poison path를 탄다. framework-level deserializer를 나중에 쓰면 동일 no-commit + invariant를 별도 evidence로 증명한다; +- DLT 성공은 error handler의 “recovered” 반환이 아니라 §25.7 ACK-aware DLT gateway 성공 뒤 + listener thread의 명시적 source ACK로만 표현한다; +- recoverer/DLT가 실패하거나 indeterminate이면 source commit 0이며, durable HOLD partition 또는 + stopped container라는 terminal automation state가 반드시 관찰돼야 한다. + +### 24.3 Receive sequence + +```text +1. ConsumerRecord receive +2. key/header/value byte bounds +3. header allowlist + envelope decode +4. envelope/payload schema/version validation +5. destination/subscription/contract allowlist +6. application command mapping +7. consume use case / MessageConsumptionExecutor +8. APPLIED 또는 DUPLICATE commit 확인 +9. acknowledgement +10. offset commit result observation +``` + +Kafka SDK type은 step 6에서 끝난다. application command는 provider-neutral event metadata와 +typed payload만 가진다. + +### 24.4 Deserialization failure + +listener method 전에 발생하는 deserializer exception도 다룬다. + +- byte[]로 먼저 받고 bounded envelope codec에서 decode하는 방식을 baseline 후보로 한다; +- framework deserializer를 쓰면 `ErrorHandlingDeserializer`/동등 error path를 명시한다; +- trusted Java package/default typing으로 arbitrary class를 만들지 않는다; +- malformed UTF-8/schema/unknown version은 무한 retry하지 않는다; +- DLT publish ACK 전 source offset을 진행하지 않는다. + +### 24.5 Ack result + +| Application result | Listener | +| --- | --- | +| APPLIED | ACK | +| DUPLICATE with same document hash/effect generation | ACK | +| RETRYABLE_FAILURE | no ACK, bounded retry | +| REJECTED/PERMANENT, reorder-tolerant subscription | DLT ACK 뒤 source ACK | +| REJECTED/PERMANENT, strict ordered subscription | idempotent quarantine 뒤 partition HOLD | +| IDENTITY_COLLISION | idempotent quarantine 뒤 subscription policy | +| DB commit outcome unknown | no ACK, retry; inbox로 reconcile | +| listener shutdown/revoke before commit | no ACK | + +ack 호출 뒤 offset commit failure도 관측한다. commit failure는 record redelivery를 만들 수 있으며 +inbox가 business duplicate를 막아야 한다. + +strict ordered subscription은 DLT ACK만으로 gap을 승인하지 않는다. operator가 audited +`ADVANCE_WITH_GAP` 또는 compensation을 승인한 뒤에만 source offset을 진행한다. + +### 24.6 Bounded processing + +- handler + bounded retry/backoff + DB pool/lock/deadlock retry + GC/scheduler reserve + + synchronous offset commit의 worst case가 `max.poll.interval.ms` 안에 들어야 한다; +- handler concurrency는 partition ordering, DB pool, executor queue에 맞춘다; +- one in-flight per partition가 first ordered baseline이다; +- first baseline은 async executor를 사용하지 않는다; +- queue saturation 때 container/partition pause로 poll heartbeat를 유지; +- capacity 회복 때 resume; +- pause가 buffer/fetch memory를 무한하게 만들지 않게 monitoring한다. + +### 24.7 Rebalance + +first synchronous card에서 rebalance callback은 long-running handler drain 장소가 아니다. handler +budget이 poll membership deadline 안에서 끝나야 하며 callback은 finite한 다음 작업만 한다. + +1. revoked partition의 신규 dispatch 중단; +2. 이미 commit된 APPLIED/DUPLICATE offset만 callback budget 안에서 commit 시도; +3. commit-failed/rebalance-in-progress는 redelivery로 분류; +4. 미완료 work는 ACK하지 않음; +5. resource/context 정리와 assignment generation 갱신. + +consumer는 thread-safe하다고 가정하지 않는다. cooperative assignor/static membership는 +rebalance evidence를 통과한 optional profile이다. off-thread processing을 추가하면 continued +polling, per-partition unfinished high-water와 listener-thread ordered ACK handoff를 별도 설계한다. + +### 24.8 Shutdown + +```text +readiness DOWN +-> listener pause/new dispatch stop +-> active DB transaction bounded drain +-> eligible ACK/commit +-> unresolved no-ACK +-> container close +-> DLT producer close +``` + +shutdown timeout 뒤 unfinished record를 ACK하지 않는다. + +### 24.9 External side effect + +consumer handler가 DB inbox transaction 안에서 HTTP/email/object storage side effect를 직접 +수행하면 same-store atomicity가 없다. + +기본 pattern: + +```text +inbox + business state + follow-up outbox intent + same DB transaction + +external side effect + 별도 durable worker/provider +``` + +외부 side effect를 반드시 inline 수행해야 하면 idempotency/reconciliation/compensation을 +feature-specific design으로 추가하고 inbox만으로 exactly-once라고 표현하지 않는다. + +## 25. Inbox, retry, DLT, replay와 Kafka EOS + +### 25.1 Inbox contract + +`application-core`는 framework-free `InboxStorePort`와 `MessageConsumptionExecutor`를 소유한다. +PostgreSQL provider는 persistence adapter가 구현한다. + +conceptual `inbox_consumption`: + +```text +consumer_id +event_id +tenant_scope NOT NULL canonical scope +contract_id +payload_version +document_sha256 +effect_contract_version +effect_generation default 0 +source_reference safe bounded diagnostic +applied_at +created_at + +PK/UNIQUE (consumer_id, effect_generation, tenant_scope, event_id) +``` + +tenant-disabled deployment도 non-null canonical system scope를 사용한다. 일반 nullable UNIQUE에 +dedupe를 맡기지 않는다. + +### 25.2 Same transaction algorithm + +```text +tx.inWrite: + validate handler/contract + INSERT ... ON CONFLICT DO NOTHING RETURNING inbox identity + inserted: + execute business mutation + optional follow-up outbox append + commit -> APPLIED + no returned row: + load existing bounded metadata + same document hash/effect version/generation -> DUPLICATE + mismatch -> IDENTITY_COLLISION +``` + +business mutation이 실패하면 inbox insert도 rollback한다. `PROCESSING` row를 먼저 별도 transaction에 +commit해 영구 stuck 상태를 만들지 않는다. plain INSERT unique exception을 catch한 뒤 같은 +PostgreSQL/JPA transaction을 계속 사용하지 않는다. native +`ON CONFLICT DO NOTHING RETURNING` 또는 동일 의미의 검증된 atomic primitive를 사용하고 두 +consumer 동시 claim을 real PostgreSQL에서 test한다. + +### 25.3 Crash behavior + +| Point | Result | +| --- | --- | +| inbox insert 전 crash | redelivery, normal apply | +| insert 뒤 business mutation 전 crash/rollback | row 없음, redelivery | +| business + inbox commit 전 crash | rollback, redelivery | +| commit 뒤 ACK 전 crash | redelivery -> DUPLICATE -> ACK | +| ACK 뒤 offset commit response loss | redelivery 가능 -> DUPLICATE | + +### 25.4 Inbox retention + +inbox retention은 최소 다음보다 길어야 한다. + +```text +Kafka replayable retention +DLT retention +maximum audited replay horizon +maximum producer duplicate/requeue horizon +cross-region/cold-recovery horizon when applicable +``` + +inbox를 먼저 지우고 Kafka/DLT record를 다시 replay하면 effect가 재적용된다. cleanup은 consumer +contract version, legal retention과 archive policy를 검증한다. + +각 consumer card는 finite `dedupeHorizon`과 source/DLT/archive replay cutoff를 pin한다. +`dedupeHorizon`은 자신이 소비하는 모든 producer contract의 `sameEventRequeueHorizon` 이상이어야 +하며 release compiler가 compatibility matrix에서 이를 검증한다. +무한 Kafka retention, legal hold 또는 cold archive가 있으면 inbox도 보존하거나 별도 durable +dedupe archive를 제공해야 한다. purge cutoff보다 오래된 replay는 자동 earliest/apply가 아니라 +unsupported incident로 fail한다. + +### 25.5 Baseline retry + +첫 consumer card는 짧고 bounded한 blocking/seek retry다. + +- retryable failure class allowlist; +- small maximum attempts; +- total elapsed bound; +- backoff가 max.poll/rebalance와 호환; +- same partition ordering 유지; +- long dependency outage를 listener thread에서 오래 sleep하지 않음; +- remaining attempts/age가 끝나면 DLT/operator path. + +구체 횟수/시간은 handler SLO와 real fault test로 고정한다. + +### 25.6 Retry topic optional card + +non-blocking retry topic은 main record를 retry topic으로 publish하고 source offset을 진행한다. +Kafka ordering을 잃으므로: + +- unordered/explicitly reorder-tolerant contract만; +- original event ID/contract/exact document hash 유지; +- retry generation/attempt metadata bounded; +- retry/DLT topic provisioning/ACL/retention; +- retry publish ACK 뒤 source ACK; +- retry ACK 뒤 source commit crash가 duplicate retry record를 만들므로 stable retry identity/dedupe; +- container transaction과 adopted Spring Kafka version의 제약 검증; +- live/retry stream의 stale effect policy + +를 요구한다. + +### 25.7 DLT + +consumer DLT는 producer-side outbox `EXHAUSTED`와 다르다. + +future consumer tuple은 별도 +`kafka-consumer-dlt-acknowledged.v1` provider card를 반드시 선택한다. 이 provider는 inbound +Kafka leaf가 소유하며 outbound messaging leaf에 의존하지 않는다. 이는 §5의 HARD invariant 3에 +둔 consumer-processing-local publisher 예외이며 closed DLT/retry binding 외 publish에는 사용할 +수 없다. 최소 exact profile: + +```text +acks=all +enable.idempotence=true +retries=effectively-unbounded/MAX within finite delivery.timeout.ms +max.in.flight.requests.per.connection<=5 +finite admission/max.block/request/delivery/buffer/record/header bounds +ByteArraySerializer key/value with prevalidated DLT bytes +closed pre-provisioned DLT binding, auto-create disabled +SASL_SSL/SCRAM least-privilege Write/Describe ACL +future metadata ACK + expected topic verification +bounded producer generation rotation/shutdown +``` + +DLT gateway outcome도 `ACKNOWLEDGED`, `ACKNOWLEDGED_MISMATCH`, `REJECTED`, +`INDETERMINATE`를 구분한다. mismatch/indeterminate/timeout/close는 source ACK를 허용하지 +않는다. application outbox producer의 card/evidence를 이름만 재사용하지 않고 consumer leaf에서 +real broker/security/fault evidence를 별도로 만든다. 공통 구현 추출은 §31.5의 module-split +trigger가 실제로 충족될 때만 한다. + +DLT record: + +- stable `dltIdentity = + hash(clusterAlias, topic, partition, offset, consumerId, effectGeneration)`; +- original event ID/contract/version/key/value 또는 approved sanitized representation; +- original topic/partition/offset safe reference; +- bounded failure code/stage; +- first/last failure timestamp; +- consumer/effect contract version; +- replay generation; +- no raw credential; +- no unbounded stacktrace/header chain. + +first DLT publish와 source offset commit은 Kafka transaction으로 원자적이지 않다. DLT ACK 뒤 +source commit 전 crash/response loss는 duplicate DLT를 만든다. DLT tooling은 `dltIdentity`로 +dedupe하고 이 crash를 test한다. + +reorder-tolerant subscription의 source ACK 조건: + +```text +DLT producer future ACKNOWLEDGED +AND DLT metadata verified +THEN source acknowledgement +``` + +DLT publish가 실패/indeterminate면 source offset을 진행하지 않는다. + +quarantine profile은 source와 같거나 더 엄격한 sensitivity ACL, encryption-at-rest, finite byte +bound와 retention을 가진다. poison raw key/header/value를 재생 가능하게 보존할지 sanitized +non-replayable evidence만 보존할지는 contract별로 하나를 고정한다. sanitized mode는 자동 replay +불가를 descriptor에 노출한다. strict ordered subscription은 successful quarantine 뒤 partition을 +listener thread에서 failed offset으로 seek한 뒤 pause/HOLD하고 audited disposition 전 source ACK를 +하지 않는다. + +strict-order HOLD는 container memory에만 두지 않는다. application-core가 +`ConsumerPartitionHoldPort`와 hold/disposition use case를 소유하고 persistence adapter가 다음 +durable control을 구현한다. + +```text +logical_subscription_id +consumer_id +effect_generation +cluster_alias +topic_binding_revision +partition +failed_offset +event_id +document_sha256 +state HOLD | ADVANCED_WITH_GAP | COMPENSATED | RELEASED_FOR_RETRY +reason/incident/approval +row_version +created_at/updated_at + +UNIQUE(logical_subscription_id, effect_generation, + cluster_alias, topic_binding_revision, partition) +``` + +- quarantine ACK와 HOLD insert는 동일한 provider transaction이 아니므로 source ACK는 여전히 + 하지 않으며, 두 결과를 reconciliation 가능한 stable identities로 기록한다; +- assignment callback은 dispatch 전에 application hold query를 호출하고 held partition을 + failed offset에 seek/pause한다; +- restart/rebalance/new pod도 durable HOLD를 다시 적용하며 in-memory pause 소실로 poison을 + 진행하지 않는다; +- operator disposition은 expected row version CAS, permission, approval/audit를 요구한다; +- `ADVANCED_WITH_GAP` 또는 verified compensation 뒤에만 listener thread가 failed offset 이후로 + 명시적 commit/resume한다; +- HOLD partition lag는 정상 retry lag와 분리하고 required subscription readiness를 DEGRADED/DOWN + 정책에 따라 표시한다. + +### 25.8 Replay + +live consumer group offset을 임의 rewind하지 않는다. 별도 replay job/group은: + +```text +replayOperationId +source (DLT/topic/archive) +contract/version allowlist +time/partition/offset/event-id scope +target consumer/effect version +reuse or new replay generation +dry-run count/hash +rate/concurrency limit +operator/approver/reason +start/stop/progress/result +``` + +를 가진다. + +기본 replay는 같은 inbox identity를 사용하므로 이미 APPLIED event는 DUPLICATE가 된다. 의도적으로 +effect를 다시 적용하려면 unique key에 참여하는 새 `effectGeneration`, business owner 승인, +compensation 위험을 명시한다. consumer/effect generation별 durable replay lease는 overlapping +replay job, live replay와 inbox cleanup race를 막는다. + +### 25.9 Offset out of range + +topic retention 뒤 offset이 사라졌을 때 자동 earliest/latest reset으로 data gap을 숨기지 않는다. +`auto.offset.reset`은 profile에 explicit하며 required consumer의 offset out-of-range는 startup 또는 +runtime incident다. replay/archive/bootstrap 절차를 선택한다. + +### 25.10 Kafka EOS optional card + +DB-free Kafka consume-process-produce는 Kafka transaction으로: + +```text +input read_committed +process +output records + source offsets in one Kafka transaction +``` + +을 구성할 수 있다. + +이는: + +- DB write; +- HTTP/email/storage side effect; +- PostgreSQL inbox; +- 다른 non-transactional system + +을 포함하지 않는다. 해당 card만 “Kafka transaction 범위의 exactly-once processing”이라고 제한해 +표현한다. + +Spring의 DB/Kafka transaction synchronization은 commit 순서를 조정할 뿐 distributed atomic +commit이 아니다. 두 번째 commit failure compensation을 별도 설계해야 한다. + +## 26. Future PostgreSQL Debezium CDC + +### 26.1 위치 + +CDC는 application process 안의 scheduler가 아니다. + +```text +PostgreSQL logical decoding +-> replication slot/publication +-> Debezium PostgreSQL connector +-> Outbox Event Router +-> Kafka Connect producer +-> Kafka topic +``` + +Java repository는 immutable event schema/contract와 deployment expected-state descriptor를 +제공한다. connector worker/image/config는 deployment asset이다. + +### 26.2 Prerequisite + +first future CDC qualification target는 다음 exact family다. + +```text +PostgreSQL 16 + pgoutput +Debezium PostgreSQL/Outbox Event Router 3.6.0.Final +Kafka Connect worker exact patch/image digest pinned by the implementation plan +snapshot.mode = no_data for cutover connectors +publication.autocreate.mode = disabled +production publication = exact outbox table + CDC row filter + INSERT only +partition_key_text VARCHAR + StringConverter key +envelope_bytes BYTEA + Debezium BinaryDataConverter value +header.converter = Kafka SimpleHeaderConverter +binary.handling.mode = bytes +errors.tolerance = none +transforms.outbox.table.op.invalid.behavior = fatal +skipped.operations = t +ordering = commit-order/detectable-sequence, strict aggregate order unsupported +``` + +resolved Connect/Kafka/plugin patch와 image digest가 없으면 이 card는 +`not-implemented`다. floating `stable/current` documentation은 discovery일 뿐 evidence가 아니다. + +CDC card를 활성화하기 전: + +- `outbox_event` 신규 row가 insert-only; +- legacy status UPDATE writer 0; +- connector invalid UPDATE behavior가 fatal/alert로 검증; +- event ID/key/envelope/schema fields가 CDC mapping 가능; +- event row에 `publication_epoch`와 `dispatch_authority=CDC`가 존재; +- PostgreSQL production publication이 exact outbox table의 + `WHERE (dispatch_authority = 'CDC')` row filter와 `publish='insert'`만 사용; +- polling/CDC wire golden parity; +- PostgreSQL logical replication prerequisites; +- dedicated publication/slot; +- Connect internal topics; +- connector/task security; +- snapshot/cutover/retention runbook; +- real end-to-end evidence + +를 모두 만족한다. + +현 mutable V3 table에 connector만 붙이는 것은 금지한다. strict aggregate ordering contract도 +§26.10의 별도 serialization/reorder card 없이 first CDC profile에 bind하지 못한다. + +### 26.3 Connector mapping + +Outbox Event Router mapping은 최소 다음을 고정한다. + +```text +event ID column -> canonical `id` header +partition_key_text VARCHAR -> StringConverter -> exact US-ASCII Kafka key bytes +envelope_bytes BYTEA -> Kafka value bytes +occurredAt column -> record timestamp policy +contract/version -> bounded headers when required +logical destination -> closed route mapping +traceparent/tracestate columns -> validated bounded headers +``` + +Debezium default `aggregateType -> dynamic topic`를 그대로 사용하지 않는다. closed logical +destination/topic allowlist와 route regex/replacement를 exact config로 관리한다. first CDC +profile은 finite DB CHECK/catalog value, exact-match route, pre-provisioned topic, +auto-create disabled와 connector ACL을 모두 사용한다. unknown route는 다른 topic으로 fallback하지 +않고 connector를 실패시킨다. + +EventRouter는 heartbeat/schema/transaction/tombstone 같은 non-outbox record에 적용하지 않는다. +exact source-topic/table SMT predicate를 사용한다. production authority filter는 scripting SMT가 +아니라 PostgreSQL 16 publication row filter로 고정한다. + +```sql +CREATE PUBLICATION +FOR TABLE ONLY .outbox_event +WHERE (dispatch_authority = 'CDC') +WITH (publish = 'insert', publish_via_partition_root = true); +``` + +`publication.autocreate.mode=disabled`와 pinned `publication.name`을 사용한다. startup/deployment +attestation은 `pg_publication`, `pg_publication_tables`/row-filter catalog를 읽어 exact table, +row filter, `pubinsert=true`, `pubupdate/pubdelete/pubtruncate=false`, +`publish_via_partition_root=true`와 다른 connector publication이 섞이지 않았음을 확인한다. +`dispatch_authority`는 PostgreSQL user-defined enum이 아니라 bounded `VARCHAR` + CHECK로 저장해 +PostgreSQL row-filter의 built-in type/operator 제약 안에 둔다. +outbox를 실제로 partition하지 않는 implementation에서도 이 값을 pin해 future partition +동작을 조용히 바꾸지 않는다. first profile은 Debezium scripting Filter SMT/plugin을 요구하지 +않는다. + +UPDATE/TRUNCATE 또는 unexpected operation을 한 설정으로 뭉뚱그리지 않는다. + +- UPDATE는 `transforms.outbox.table.op.invalid.behavior=fatal`로 EventRouter가 connector를 + 중지하게 한다. INSERT-only publication 때문에 정상적으로 관찰될 수 없고, publication drift에 + 대한 defense-in-depth다. 기본값 `warn`은 허용하지 않는다; +- DELETE/TRUNCATE는 production publication에서 publish하지 않는다. §26.13의 승인된 retention + DELETE가 production event/tombstone을 emit하지 않음을 검증한다; +- `skipped.operations=t`도 connector defense-in-depth로 pin한다. runtime role의 + DELETE/TRUNCATE privilege 제거와 migration gate/audit가 주 방어선이며, 예상 밖 + UPDATE/DELETE/TRUNCATE가 DB audit에서 발견되면 connector를 중지하고 + `DATA_GAP_SUSPECTED`로 전이한다; +- `errors.tolerance=none`은 converter/SMT가 실제로 throw한 오류를 skip/DLQ로 우회하지 않는 + 정책이지 UPDATE/TRUNCATE 자체의 분류 설정이 아니다. + +polling-era event는 PostgreSQL publication row filter에서 production CDC source에 들어오지 +않는다. shadow connector만 별도 insert-only publication/slot과 격리 topic에서 authority 전체를 +비교할 수 있다. + +### 26.4 Insert-only behavior + +- application은 event row를 UPDATE하지 않는다; +- polling state는 delivery table에만 있다; +- cleanup DELETE는 retention proof 뒤에만 실행하고 INSERT-only publication의 production output + 0을 검증; +- update event가 관찰되면 + `transforms.outbox.table.op.invalid.behavior=fatal`로 warning 없이 stop/alert; +- CDC connector는 outbox table만 capture하도록 include list/predicate를 제한한다. +- runtime role의 UPDATE/DELETE/TRUNCATE/DDL privilege를 제거하고 migration role만 별도 승인; +- exact publication row filter + `publish=insert`, `skipped.operations=t`, table list, + partition-root behavior를 config/catalog attestation으로 pin; +- DROP/DETACH/TRUNCATE는 logical decoding alert에만 의존하지 않고 migration gate/audit에서 차단. + +### 26.5 Payload + +first CDC profile은 PostgreSQL `binary.handling.mode=bytes`로 읽은 `BYTEA envelope_bytes`를 +EventRouter 결과 value로 만들고 +`value.converter=io.debezium.converters.BinaryDataConverter`로 exact bytes를 emit한다. +heartbeat 같은 non-outbox record는 BinaryDataConverter가 처리할 수 없으므로 공식 +`value.converter.delegate.converter.type=org.apache.kafka.connect.json.JsonConverter`와 +`value.converter.delegate.converter.type.schemas.enable=false` 설정을 pin한다. delegate가 만든 +record는 exact source-table predicate와 closed route에 의해 production event topic으로 들어갈 수 +없어야 한다. `JsonConverter`/String expansion으로 event envelope 자체를 다시 직렬화하는 +profile은 first profile이 아니다. + +key는 `partition_key_text VARCHAR(64)`를 EventRouter key field로 선택하고 +`key.converter=org.apache.kafka.connect.storage.StringConverter`로 직렬화한다. polling +producer가 쓰는 US-ASCII bytes와 동일함을 golden vector로 검증한다. event ID의 EventRouter 기본 +`id` header를 canonical header로 채택하며 alias를 하나 더 남기지 않는다. +`header.converter=org.apache.kafka.connect.storage.SimpleHeaderConverter`를 default에 맡기지 않고 +explicit pin한다. event ID/contract/version/trace header source column은 bounded canonical +ASCII/UTF-8 STRING Connect type으로 유지하고 null/optional placement을 exact config로 고정한다. +polling의 exact UTF-8 header bytes와 adopted Kafka Connect version의 +SimpleHeaderConverter output을 golden test로 비교한다. key/header binary representation과 full +SMT/converter chain을 golden test로 고정한다. + +다음이 polling과 같아야 한다. + +- event ID; +- key bytes; +- contract/payload/envelope versions; +- exact value bytes와 `envelope_sha256`; +- required headers; +- record timestamp policy. + +malformed JSON은 writer admission에서 거부되어야 한다. CDC converter가 malformed string을 정상 +string value로 우회시키는 profile은 qualification 실패다. + +### 26.6 Offset와 internal topics + +Kafka Connect distributed mode는 최소: + +- config storage topic; +- offset storage topic; +- status storage topic + +의 partition/RF/cleanup/ACL을 운영 profile로 고정한다. offset reset/alter는 destructive audited +operation이다. connector REST API 노출과 authorization도 제한한다. + +connector/task restart 뒤 마지막 committed offset부터 중복 change event가 재방출될 수 있다. +consumer inbox가 이를 흡수해야 한다. + +source connector producer도 application producer card의 보장을 자동 상속하지 않는다. exact +Connect worker/connector profile은 최소 다음을 별도로 pin한다. + +```text +acks=all +enable.idempotence=true +max.in.flight.requests.per.connection<=5 +finite delivery/request/max.block/buffer/request bounds +SASL_SSL/SCRAM credential and least-privilege exact-topic ACL +topic auto-creation disabled +errors.tolerance=none +converter/SMT failure -> task FAILED, no skip +``` + +Connect internal topic producer/consumer 권한과 outbox destination 권한은 분리한다. + +### 26.7 Replication slot와 WAL + +monitor: + +```text +slot exists/active +confirmed_flush_lsn/restart_lsn +current WAL LSN +retained WAL bytes +connector source lag +last event/heartbeat +database disk free +publication/table inclusion +slot catalog state +``` + +low-traffic database는 heartbeat/action query가 WAL progress에 미치는 영향을 exact connector +version으로 검증한다. + +slot drop/recreate는 이전 LSN history를 복구하지 못할 수 있고 silent gap을 만들 수 있다. 자동 +recreate 뒤 healthy 표시를 금지한다. + +`max_slot_wal_keep_size`, database disk free admission과 operator emergency threshold를 finite +deployment 값으로 pin한다. cap 초과로 required WAL segment가 제거되면 slot/card는 +`DATA_GAP_SUSPECTED`이며 resnapshot/reconciliation 전 READY로 복귀하지 않는다. monitoring만 +있고 WAL/disk bound가 없는 profile은 R2가 아니다. + +### 26.8 Snapshot + +first shadow와 production cutover connector는 `snapshot.mode=no_data`를 사용한다. 각 connector의 +unique logical slot을 writes-frozen boundary에서 생성하고 slot consistent point 이후 INSERT만 +stream한다. existing outbox history를 snapshot으로 다시 publish하지 않는다. initial snapshot이 +필요한 다른 profile은 historical duplicate scope와 inbox coverage를 별도 card로 증명한다. + +snapshot/restart 중 duplicate, schema change, queue saturation, connector crash를 test한다. + +### 26.9 PostgreSQL 16 failover limitation + +repository의 first CDC database target은 PostgreSQL 16이다. 이 profile은 PostgreSQL 17+의 +failover logical slot continuity를 주장하지 않는다. + +- primary failover 시 connector/card는 즉시 NOT_READY; +- 새 primary의 slot, Connect offset과 source LSN을 수동 reconcile; +- missing/ahead/behind 또는 WAL loss가 있으면 `DATA_GAP_SUSPECTED`; +- resnapshot/audited backfill/inbox reconciliation 전 writes 재개 조건을 runbook으로 판단; +- automatic slot recreate와 no-gap 표현 금지. + +PostgreSQL 17+ synchronized failover slot은 별도 future card와 real failover evidence가 있을 때만 +추가한다. + +### 26.10 Ordering compatibility + +logical decoding은 transaction commit order를 emit한다. application이 allocate한 +`aggregateSequence`와 commit order는 두 transaction이 역순 commit하면 다를 수 있다. + +따라서 first CDC card는: + +- strict aggregate ordering guarantee를 제공하지 않는다; +- key와 aggregate sequence를 보존해 gap/regression을 탐지 가능하게 한다; +- shadow compare도 global/aggregate sequence order가 아니라 source offset, event set, key와 exact + bytes를 비교한다. + +strict ordered CDC가 필요하면 같은 aggregate transaction serialization으로 +`sequence order == commit order`를 증명하거나 Kafka 전 reorder/consumer sequence gate를 가진 +별도 card가 필요하다. + +### 26.11 DDL와 source limitations + +qualification은 최소 다음을 다룬다. + +- logical decoding이 DDL event를 직접 제공하지 않는 한계; +- outbox schema migration과 rolling application; +- partition root publication behavior; +- primary key/replica identity 변경; +- TOAST/unchanged value behavior가 envelope column에 미치는 영향; +- delete/tombstone; +- TRUNCATE/DROP/DETACH와 `skipped.operations`; +- connector/plugin/JDBC/PostgreSQL version compatibility. + +### 26.12 CDC readiness + +CDC readiness는 polling metric을 재사용하지 않는다. + +```text +connector/task RUNNING +AND slot/publication valid +AND WAL retained bytes within bound +AND topic/security/schema attested +AND last cutover epoch/watermark reconciled +AND expected connector name/config/image/SMT hash matches +AND expected task count/source producer profile matches +AND last successful source interaction/offset commit is fresh for observed source activity +``` + +idle database에서 LSN이 움직이지 않는다는 이유만으로 DOWN시키지 않는다. worker/task liveness, +last source interaction, actual source activity, Kafka offset commit freshness와 measured lag를 +분리한다. `RUNNING`이나 stale prior attestation만으로 no-gap/READY를 주장하지 않는다. +application liveness와 분리한다. + +### 26.13 CDC retention proof + +time partition이 오래됐다는 이유만으로 삭제하지 않는다. + +closed partition의 모든 source transaction/batch와 destination partition이: + +1. persisted Connect source offset과 slot checkpoint에서 coverage됐고; +2. expected Kafka destination의 event ID/hash manifest에서 관찰됐고; +3. replay retention이 지났고; +4. connector offset/slot continuity가 검증되었고; +5. legal/operator hold가 없다는 + +구체적 proof를 남긴 뒤 purge한다. 한 sentinel의 한 Kafka partition 관찰이나 +`confirmed_flush_lsn` 하나만으로 다른 destination partition coverage를 추론하지 않는다. + +## 27. Polling/CDC shadow, cutover와 rollback + +### 27.1 Mutual exclusion은 두 층이다 + +application setting 하나만으로 외부 Connect deployment를 막을 수 없다. + +두 층을 모두 사용한다. + +1. database publication epoch/dispatch authority fence; +2. infrastructure authority: deployment replica state, connector state, producer/connector ACL. + +같은 production topic에 polling producer principal과 CDC connector principal의 Write authority를 +동시에 열지 않는다. + +`outbox_publication_epoch` conceptual control row: + +```text +epoch_id monotonic PK +authority LEGACY_POLLING | POLLING_V2 | CDC +state PREPARED | ACTIVE | RETIRED +source_boundary +settings_digest +activated_at +activated_by/approved_by +row_version +``` + +정확히 한 ACTIVE row를 partial unique constraint로 보호한다. append adapter는 business +transaction 안에서 ACTIVE row를 `FOR SHARE`로 읽고 event에 `epoch_id/authority`를 기록한다. +`POLLING_V2`면 같은 transaction에서 delivery row를 만들고, `CDC`면 만들지 않는다. epoch +activation은 같은 row를 `FOR UPDATE`로 전환하므로 concurrent append와 serializes한다. + +old binary가 cached config로 다른 mode를 쓰지 못하도록: + +- target append는 DB epoch를 authority로 사용; +- activation 전 old writer/relay binary 0을 deployment fingerprint로 확인; +- DB trigger/constraint가 stale epoch, CDC event의 delivery row, polling event의 delivery 누락을 + 거부; +- active authority와 맞지 않는 legacy status mutation을 DB guard가 거부한다. + +application setting은 expected epoch/authority assertion일 뿐 DB truth를 덮지 않는다. + +### 27.2 Shadow qualification + +shadow CDC는: + +- production과 다른 topic; +- production consumer가 읽지 않는 group; +- same immutable event source; +- same schema/key mapping; +- event ID/count/exact byte hash/key/source-offset/lag compare; +- bounded retention와 restricted ACL + +을 사용한다. + +shadow record를 production effect에 적용하지 않는다. shadow 성공은 cutover rehearsal/evidence지 +production CDC ACTIVE가 아니다. + +topology는 두 connector/두 slot으로 고정한다. + +```text +shadow connector + shadow logical slot + shadow topic +production connector + production logical slot + production topic +``` + +slot이나 Connect offset을 공유·복사·재사용하지 않는다. shadow connector의 advanced offset을 +production topic으로 retarget하지 않는다. production connector/slot은 writes-frozen cutover에서 +새로 만든다. + +### 27.3 Cutover invariant + +cutover는 다음을 증명해야 한다. + +```text +모든 pre-cutover event가 polling 또는 reconciled duplicate로 처리 +AND 모든 post-cutover event가 CDC source boundary에 포함 +AND 같은 event가 두 authority에서 production topic으로 발행되는 window가 없음 +AND rollback boundary가 기록됨 +``` + +### 27.4 Polling -> CDC high-level sequence + +exact Debezium 3.6.0.Final/PostgreSQL 16/Connect worker runbook이 세부 명령을 소유한다. 고수준 +순서: + +1. 별도 shadow connector/slot/topic을 `no_data`로 qualification; +2. bounded write maintenance 시작, 신규 business transaction admission 중단, active write drain; +3. polling new claim 중단, active attempt drain, indeterminate/backlog reconcile; +4. polling producer production Write ACL revoke, producer close, old relay fence 확인; +5. writes가 frozen인 상태에서 exact CDC row filter + INSERT-only인 dedicated production + publication과 **새 production slot** 생성; +6. slot creation이 반환한 consistent point/source boundary와 empty Connect offset identity 기록; +7. `snapshot.mode=no_data`, `publication.autocreate.mode=disabled`, pinned publication, + exact source-table SMT predicate, StringConverter-key/BinaryDataConverter-value chain인 + production connector를 준비하고 exact topic Write ACL만 부여; +8. DB transaction에서 new CDC epoch ACTIVE 전환과 CDC-authority cutover sentinel INSERT를 + 원자적으로 수행; sentinel에는 polling delivery row가 생기지 않음; +9. production connector를 start/resume하여 unique slot boundary부터 stream; +10. production topic에서 sentinel exact event ID/hash를 확인하고 persisted Connect offset, + slot LSN과 reconcile; +11. target application expected epoch를 확인한 뒤 regular writes 재개; +12. event set/key/hash/source lag와 consumer inbox duplicate를 관측; +13. rollback window 동안 polling artifacts와 production slot/offset을 보존. + +`pg_current_wal_lsn()`을 application transaction에서 읽은 값이나 shadow connector offset을 commit +boundary로 추정하지 않는다. authoritative boundary는 writes-frozen 상태에서 생성한 production +logical slot consistent point, persisted Connect source offset과 CDC-epoch marker transaction의 +correlation evidence다. + +### 27.5 CDC -> polling high-level sequence + +1. incident/cutback 승인, 신규 business write admission fence와 active write transaction drain; +2. ACTIVE epoch를 `FOR SHARE`로 잡고 있던 writer가 0이고 writes가 frozen임을 DB session/epoch + evidence로 확인; +3. current CDC epoch의 **마지막** transaction으로 controlled rollback-boundary sentinel INSERT; +4. connector가 sentinel을 Kafka에 emit하고 sentinel transaction end LSN까지의 source offset과 + slot high-water coverage가 persisted됐는지 확인; +5. connector pause/stop, exact offset/LSN 기록, production Write ACL revoke; +6. polling producer/topic/security를 attest하되 scheduler는 아직 정지; +7. DB transaction에서 새 POLLING_V2 epoch ACTIVE 전환과 polling sentinel + event+delivery INSERT; +8. polling producer Write ACL grant와 scheduler start; +9. polling sentinel `DELIVERY_RECORDED`와 Kafka record 확인; +10. target application expected epoch 확인 뒤 regular writes 재개; +11. suspected CDC gap만 immutable event에서 audited delivery generation으로 backfill; +12. duplicate/inbox/reconciliation 확인. + +sentinel event ID만 보였다는 이유로 connector를 멈추지 않는다. writes-frozen boundary, +sentinel transaction end LSN, persisted Connect source offset와 slot state가 같은 high-water를 +가리켜야 한다. maintenance 전에 시작한 transaction이 sentinel 뒤 commit할 수 있는 상태에서는 +step 3으로 진행하지 않는다. + +CDC-era event 전체를 무조건 polling delivery로 backfill하지 않는다. 이미 emit된 event를 대량 +duplicate할 수 있기 때문이다. 범위와 certainty를 계산한 audited backfill만 허용한다. + +### 27.6 Rollback + +cutover 실패 시: + +- 어느 authority가 마지막으로 production Write를 가졌는지; +- 마지막 confirmed event ID/WAL/offset; +- indeterminate range; +- consumer inbox coverage; +- duplicate-safe replay 범위; +- slot/offset 보존 여부 + +를 먼저 판정한다. “둘 다 켜서 빨리 복구”는 허용하지 않는다. + +### 27.7 Automatic failover 금지 + +CDC connector health DOWN을 감지해 application이 polling을 자동 활성화하지 않는다. external +connector와 in-process scheduler 사이 split-brain을 만들기 때문이다. bounded backlog가 source +DB에 남고 required WAL이 finite retention bound 안에 있는 동안 operator-run cutover/rollback을 +수행한다. WAL loss가 생기면 automatic failover가 아니라 `DATA_GAP_SUSPECTED` reconciliation이다. + +## 28. Test strategy + +### 28.1 원칙 + +messaging readiness는 fake 하나나 happy-path broker 하나로 증명하지 않는다. + +```text +pure contract/property ++ application state machine ++ real PostgreSQL ++ real Kafka ++ security/topology ++ fault/crash/lifecycle ++ compatibility += exact card evidence +``` + +unit test는 빠른 feedback이고 real-service test는 실제 guarantee evidence다. 둘 중 하나가 다른 +하나를 대체하지 않는다. + +### 28.2 Current characterization + +첫 변경 전에 현재 behavior를 고정한다. + +- broker blank -> disabled sentinel; +- broker selected + sender 없음 -> startup failure; +- broker ID mismatch -> startup failure; +- current sender normal return -> current PUBLISHED transition; +- current exception -> FAILED/DEAD; +- ACK-to-mark failure -> IN_FLIGHT/reclaim duplicate possibility; +- same-transaction append rollback; +- current timestamp FIFO; +- current runbook/config drift 목록. + +characterization은 current behavior를 정당화하는 것이 아니라 migration 중 accidental loss를 +방지한다. + +### 28.3 Contract catalog unit/property + +- duplicate contract/destination/schema IDs; +- unknown provider/card; +- maximum bound intersection; +- ordering-required + null key; +- deterministic partition key golden vectors; +- tenant/no-tenant scope; +- aggregate sequence/eventIndex uniqueness; +- tenant ACTIVE/DISABLED non-null scope uniqueness; +- stable catalog/schema/settings digest; +- config cannot relax code maximum; +- legacy + target conflict; +- disabled resource-0 descriptor; +- unsupported card rejection. + +### 28.4 JSON Schema v1 + +최소: + +- Draft 2020-12 meta-schema validation; +- immutable `$id`와 checksum; +- no remote `$ref`; +- offline meta-schema/vocabulary registry, duplicate `$id`, cyclic `$ref`, pathological regex; +- valid/invalid envelope golden corpus; +- contract별 valid/invalid payload; +- required/null/missing; +- unknown property; +- duplicate JSON key; +- invalid UTF-8/unpaired surrogate; +- depth/string/array/object/number bound; +- timestamp/format assertion; +- exact UTF-8 bytes; +- envelope/header mismatch; +- N/N-1 rolling vectors와 replay horizon 전체 version vectors; +- retired/future payload version; +- parser CPU/memory/time bound. + +official JSON Schema Test Suite 또는 Bowtie 호환성 증거를 adopted validator에 대해 추가한다. +그 결과가 모든 custom contract compatibility를 대신한다고 주장하지 않는다. + +### 28.5 Application-core + +- business + event append same transaction; +- validation failure rolls back business write; +- publication outcome exhaustive mapping; +- ACKNOWLEDGED/ACKNOWLEDGED_MISMATCH/REJECTED/INDETERMINATE; +- acceptance certainty와 retry disposition의 독립 mapping; +- permanent failure no repeated retry; +- combined attempt/elapsed budget; +- report only after persisted transition; +- reporter failure containment; +- exhausted ordered head blocking; +- audited requeue/hold/skip/compensate policy와 authorization; +- same-event requeue horizon cutoff; +- operator endpoint unauthenticated/forbidden/destructive-permission, idempotency와 stale + generation/row-version rejection; +- late-completion bounded source/drain race, duplicate drain과 persistence failure; +- no provider/SDK type in contract; +- disabled active-contract failure. + +future consumer: + +- APPLIED/DUPLICATE/RETRY/REJECTED mapping; +- inbox same transaction; +- payload collision; +- follow-up outbox same transaction; +- external side effect prohibited/defaulted to outbox. + +### 28.6 PostgreSQL polling integration + +real PostgreSQL Testcontainers lane: + +- forward-only migration from V3; +- business + event + delivery commit/rollback; +- same transaction resource identity mismatch startup rejection; +- immutable event update rejection after cutover; +- exact `BYTEA` round trip와 envelope hash; +- delivery FK/unique/order constraints; +- tenant scope nullable-dedupe 공격; +- two or more workers disjoint claim; +- same aggregate same timestamp with sequence/eventIndex total order; +- different aggregates parallel progress; +- stale token cannot ACK/FAIL/DEAD; +- expired-but-not-yet-reclaimed token cannot record delivery; +- lease expiry during send; +- same-token renew; +- database time authority; +- initial automatic publication age starts at event DB created_at; +- requeue generation deadline is + `min(generation DB created_at + maximum age, event created_at + same-event requeue horizon)`; +- ACK-to-mark crash; +- append-only attempt admission/outcome/late-ACK journal; +- late observation DB commit before source ACK, commit-to-ACK crash duplicate absorption; +- late observation queue overflow/drop does not mutate delivery state; +- claim count와 publication attempt count; +- mark transaction failure; +- exhausted head/fairness/hot aggregate; +- one CURRENT generation partial-unique constraint; +- concurrent requeue generation race와 atomic authority handoff rollback; +- EXHAUSTED/HOLD supersede 뒤 old generation이 다시 claim되지 않음; +- legacy/v2 relay authority epoch and no dual claim/send; +- legacy PUBLISHED -> unverified migration only; +- reaper vs claim/requeue; +- retention partition/FK/audit; +- DB pool/batch capacity. + +Docker unavailable이면 required R2 lane는 skip이 아니라 failure다. + +### 28.7 Real Kafka producer integration + +real Kafka broker에서: + +- successful send returns actual topic/partition/offset metadata; +- metadata destination mismatch fatal incident; +- `acks=all` effective config; +- idempotence conflict startup failure; +- stable key/partition; +- record/header too large; +- missing topic with auto-create disabled; +- unauthorized Write/Describe; +- broker unavailable before send; +- leader move/retriable error; +- response loss/deadline/late ACK; +- local buffer saturation/max-block; +- broker throttle; +- retry ordering; +- old/new producer generation barrier와 forced indeterminate order downgrade; +- fatal/defunct producer generation recreation; +- no per-message flush; +- producer generation rotation; +- graceful drain/forced close; +- unresolved shutdown -> indeterminate; +- duplicate after ACK-to-DB gap; +- low-cardinality metric/trace/log. + +single-node Testcontainers Kafka는 HA/min ISR/leader-failover 전체 증거가 아니다. R2 topology +profile에 필요한 multi-broker scenario는 별도 lane에서 수행한다. + +### 28.8 Security integration + +- trusted TLS success; +- untrusted CA failure; +- hostname mismatch failure; +- expired/not-yet-valid certificate; +- SASL valid/invalid credential; +- least-privilege producer ACL; +- denied Create/Delete/Alter; +- secret absent/expired; +- credential rotation old/new generation; +- production plaintext startup rejection; +- config/log/descriptor secret redaction. + +### 28.9 Topic conformance + +- expected partition/RF/min ISR; +- cleanup/retention/max bytes drift; +- topic missing; +- partition expansion mismatch; +- auto-create disabled; +- wrong cluster/topic binding; +- provisioning evidence/card fingerprint; +- readiness recovery after operator correction. + +### 28.10 Fault/crash matrix + +process kill/fault injection 지점: + +```text +after DB event commit +after claim commit +before Kafka send +after request write +after broker append before ACK receipt +after ACK before DB transition +during DB transition commit +after DB success before scheduler result +during shutdown +``` + +각 시나리오는 DB state, broker observed event IDs, duplicates, late callback, claim token과 recovery를 +검증한다. + +### 28.11 Capacity and soak + +- sustained throughput와 backlog drain; +- hot aggregate; +- many bounded contracts/destinations; +- producer buffer/memory/GC; +- DB claim query/index; +- poll batch vs claim lease; +- broker throttle/outage/recovery storm; +- retry amplification; +- shutdown under load; +- metric cardinality; +- long-running secret generation rotation. + +benchmark 숫자는 repository/device 일반 성능 주장으로 사용하지 않고 selected deployment +capacity evidence로 기록한다. + +### 28.12 Future consumer integration + +inbound card가 구현될 때: + +- auto commit disabled/manual immediate ACK; +- max.poll.records=1 synchronous listener-thread ACK, wrong-thread ACK rejection; +- `ackAfterHandle=false`, `commitRecovered=false`, + `resetStateOnRecoveryFailure=false`, default logging recoverer absent; +- unexpected exception retry exhaustion -> durable HOLD + seek/pause + source commit 0; +- recoverer/HOLD persistence failure -> bounded container stop/readiness DOWN, no retry-cycle reset; +- explicit sync commit timeout/failure/redelivery; +- commit 후 ACK; +- commit 뒤 ACK 전 crash duplicate; +- same event duplicate no side effect; +- two consumers concurrent `ON CONFLICT DO NOTHING RETURNING`; +- tenant scope/effect generation uniqueness; +- same event different hash quarantine; +- malformed bytes/schema/unknown version; +- pause/resume under bounded saturation; +- max.poll interval; +- rebalance revoke/assign while active; +- partition ordering; +- short retry; +- DLT exact ACK-aware provider effective config/metadata mismatch/rotation/shutdown; +- DLT ACK success/failure/indeterminate; +- DLT ACK 뒤 source commit crash의 duplicate DLT identity; +- strict ordered durable quarantine HOLD, restart/reassignment reapply와 approved gap CAS; +- header sanitization/growth; +- inbox retention/replay; +- live/replay overlap, effect-generation lease와 cleanup race; +- offset out of range; +- graceful shutdown; +- TLS/SASL/ACL; +- real consumer lag/metrics/trace. + +### 28.13 Future CDC integration + +exact PostgreSQL + Kafka + Connect + Debezium image set: + +- insert-only event mapping; +- `table.op.invalid.behavior=fatal` UPDATE stop과 `skipped.operations=t`/DB-audit TRUNCATE policy; +- exact CDC-authority row-filtered INSERT-only PostgreSQL publication catalog; +- polling-authority row 0 emission, approved retention DELETE output 0; +- polling/CDC key/envelope golden parity; +- `partition_key_text`/StringConverter key parity, + `BYTEA`/Debezium BinaryDataConverter exact value bytes, canonical `id` header와 alias 0; +- pinned SimpleHeaderConverter와 polling/CDC exact header-byte parity; +- non-outbox heartbeat/schema/tombstone predicate; +- unknown route + auto-create disabled + ACL failure; +- `errors.tolerance=none` poison/converter failure; +- source producer idempotence/security/effective config; +- connector task crash/restart duplicate; +- Connect internal offset commit failure after Kafka append; +- Connect offset commit/replay; +- slot missing/drop/recreate; +- WAL retained/disk threshold; +- heartbeat low traffic; +- snapshot/no-data mode; +- existing history not republished by cutover `no_data`; +- schema migration during connector lifecycle; +- partitioned table mapping; +- PostgreSQL 16 failover -> NOT_READY/DATA_GAP_SUSPECTED, no no-gap claim; +- reverse commit-order aggregate sequence incompatibility; +- shadow event ID/count/hash; +- cutover sentinel; +- cutover crash/abort after every numbered step; +- polling/CDC Write authority exclusivity; +- publication epoch stale-writer/old-binary fence; +- rollback and audited backfill; +- retention checkpoint proof. + +### 28.14 Compatibility + +matrix: + +```text +old producer -> new consumer +new producer -> old live consumer +old polling binary -> new DB schema +new polling binary -> compatibility DB schema +old/new Kafka client -> selected broker +old/new connector -> selected PostgreSQL/Kafka +rolling credential/schema/catalog generation +``` + +unsupported combination을 명시하고 자동 fallback하지 않는다. + +### 28.15 Observability contract + +test는 metric이 “존재한다”뿐 아니라: + +- logical vs physical attempt 구분; +- outcome/certainty 정확성; +- ACK latency boundary; +- event/tenant/key/payload tag 부재; +- catalog cardinality enforcement; +- one canonical ERROR; +- trace context validation; +- readiness role 분리; +- stale health/hysteresis; +- sanitized capability descriptor + +를 검증한다. + +## 29. Gradle, CI, dependency와 supply chain + +### 29.1 Dependency ownership + +| Dependency | Owner | +| --- | --- | +| Spring Kafka/Kafka producer client | `adapter:outbound:messaging` | +| JSON/schema validator runtime | outbound messaging; future inbound leaf도 자기 runtime 소유 | +| envelope schema resource/vocabulary | `shared-contract` when truly generic | +| PostgreSQL/JPA/Flyway | `adapter:outbound:persistence-jpa` | +| future Kafka listener client | `adapter:inbound:messaging-kafka` | +| Kafka Connect/Debezium | deployment/integration-test asset | +| Testcontainers Kafka | provider/integration test configuration | +| Testcontainers PostgreSQL | persistence/bootstrap integration test | +| Micrometer/Spring observation composition | adapter/bootstrap ownership에 맞춤 | + +application/domain은 Kafka/Jackson/schema validator에 의존하지 않는다. + +### 29.2 Spring Boot BOM + +Spring Boot 4.0.0 BOM이 관리하는 Spring Kafka/Kafka client 조합을 first implementation +candidate로 사용한다. 실제 resolved version은 dependency lock과 evidence fingerprint로 기록한다. + +direct version override는: + +- Boot/Spring Kafka compatibility; +- Kafka broker protocol compatibility; +- CVE/license; +- tests/locks/SBOM + +을 함께 통과할 때만 허용한다. + +### 29.3 Architecture change + +producer/polling phase는 현재 19 leaf를 유지한다. + +first R2 operator surface는 existing +`adapter-inbound-web -> application-core`와 +`adapter-outbound-persistence-jpa -> application-core` edges를 composition root에서 조립한다. +web leaf가 persistence leaf/repository/entity에 직접 의존하지 않으므로 새 project edge가 없다. + +standalone sample이 real messaging example을 실행하는 phase에는 leaf 수를 늘리지 않고 +`sample-portfolio`의 allowed dependency/runtime dependency에 +`adapter-outbound-messaging` edge만 추가한다. provider qualification 자체는 adapter +test-source contract로 가능하므로 이 sample edge가 production R2의 필수 전제는 아니다. + +consumer phase는: + +- registry에 20번째 leaf; +- settings include/mapping; +- app-bootstrap dependency; +- architecture tests/fixtures; +- module lockfile; +- documentation + +을 한 migration으로 추가한다. inbound leaf에서 outbound/persistence adapter로 edge를 만들지 않는다. + +### 29.4 Proposed tasks + +구현 계획은 repository task naming convention을 확인한 뒤 정확한 이름을 확정한다. 목표 lane: + +```text +:adapter:outbound:messaging:test +:application-core:test +:adapter:outbound:persistence-jpa:test +:app-bootstrap:test + +verifyMessagingContracts +verifyMessagingJsonSchemaV1 +verifyMessagingPollingOutboxR2 +verifyMessagingKafkaProducerR2 +verifyMessagingSecurityR2 +verifyMessagingReleaseProfile + +future: +verifyMessagingConsumerR2 +verifyMessagingCdcR2 +``` + +기존 공통 gate: + +```text +test +check +verifyCleanArchitectureDependencies +verifyEnvKeys +verifyPublicPathSnapshot +dependency lock/SBOM/vulnerability/license gates +``` + +### 29.5 CI lanes + +PR blocking: + +- pure/unit/property; +- architecture/dependency/env/schema registry contract; +- JSON schema/golden/N/N-1; +- real PostgreSQL polling baseline; +- pinned real Kafka producer baseline; +- docs/config/runbook drift checks. + +production-readiness: + +- TLS/SASL/ACL; +- topic conformance; +- selected RF/min ISR의 multi-broker leader loss, below-min-ISR rejection과 recovery; +- multi-worker stale-token/crash matrix; +- response-loss/Toxiproxy; +- bounded shutdown/rotation; +- metrics/readiness artifact. + +nightly/R3: + +- prolonged leader churn과 repeated multi-broker failure; +- rolling broker/client upgrade; +- partition migration; +- soak/capacity; +- future consumer rebalance storm; +- future CDC restart/slot/failover. + +### 29.6 No silent skip + +developer local test는 Docker 없음 등을 명시적으로 보고할 수 있다. 그러나 selected R2 release gate는: + +- required service unavailable; +- image pull failure; +- test skipped by assumption; +- credential fixture missing; +- no matching test; +- stale evidence + +를 PASS로 변환하지 않는다. + +### 29.7 Evidence artifact + +sanitized artifact: + +```text +git commit/source digest supplied by human/CI +commands and timestamps +task/test counts +broker/client/Spring/PostgreSQL/Connect/Debezium versions +image digests +effective non-secret settings +topology/security profile +schema/catalog/settings hashes +fault scenarios/results +readiness/card status +skips/failures +unsupported claims +runbook links +``` + +CI artifact 없이 문서 표를 수동으로 R2로 바꾸지 않는다. + +### 29.8 Supply chain + +- Gradle dependency locks; +- SBOM; +- vulnerability severity/suppression policy; +- license allowlist; +- Kafka/Connect/Debezium container digest pin; +- connector plugin inventory/classloader compatibility; +- JSON schema validator dependency review; +- image signature/provenance where platform supports; +- upgrade cadence/runbook. + +## 30. Implementation and migration sequence + +### 30.1 Phase 0 — Truth and characterization + +목표: + +- 이 설계 검토/승인; +- current ACK overclaim 표시; +- fake-only/real-service status 정리; +- current tests characterization; +- README/YAML/env/runbook drift 목록; +- implementation plan 작성. + +완료 기준: + +- §0 status ledger 갱신; +- current code behavior test; +- no implementation/R2 overclaim; +- human-only git policy 유지. + +### 30.2 Phase 1 — Contract, catalog, envelope and JSON Schema + +추가: + +- application event metadata/value types; +- application-owned contract contribution SPI와 합법적인 sample/test composition; +- logical destination/contract descriptors; +- validated integration-event port; +- envelope v1/payload schema resources; +- schema/catalog checksum manifest; +- JSON codec/validator; +- sample payload contract/golden vectors; +- shared-contract/messaging leaf `CLAUDE.md` ownership update; +- compatibility/no-remote-ref/resource-bound tests; +- current raw payload/eventType migration adapter. + +이 phase만으로 Kafka/polling R2가 아니다. + +### 30.3 Phase 2 — Immutable event and polling delivery v2 + +기존 V3 migration을 수정하지 않고 새 forward-only migration을 추가한다. + +base template의 기본 migration card는 +`ADDITIVE_IN_PLACE_EMPTY_OR_DRAINED_V3.v1`이다. production data가 없거나 verified drain/reset +maintenance로 legacy row가 0인 새 템플릿 출발점을 대상으로 한다. 이 기본 card에서 P2의 +rolling-safe 고수준 순서: + +1. row/data classification preflight가 empty-or-drained 조건을 fail-closed로 확인; +2. existing `outbox_event`에 immutable v2 metadata를 additive하게 추가하고 legacy columns는 + compatibility를 위해 유지; +3. `outbox_delivery`, append-only attempt journal, disposition audit, + `outbox_publication_epoch` 생성; +4. known legacy event type alias/catalog와 empty/drained evidence를 기록; +5. compatibility release가 legacy required columns와 v2 immutable metadata를 채우되 + `LEGACY_POLLING` control만 사용; + retained V3 compatibility projection은 `event_type=contract_id`, `payload=exact v1 envelope + UTF-8 text`, `status=PENDING`, `attempt_count=0`, `next_attempt_at=occurred_at`, + `idempotency_key=event_id`로 고정한다. canonical metadata가 있는 row의 legacy publisher는 + `OutboxEnvelopeJson`으로 다시 감싸지 않는다. compiled logical-destination binding, stored + partition-key bytes와 immutable `envelope_bytes`를 byte-for-byte passthrough한다. canonical + metadata가 없는 true legacy row만 기존 v0 wrapper를 사용한다; +6. compatibility binary의 legacy relay query가 ACTIVE + `LEGACY_POLLING` epoch/generation만 처리하도록 fence; +7. v2 constraint/index/trigger, one-CURRENT authority와 migration rollback을 rehearsal하되 active + legacy status mutation을 아직 막지 않음; +8. old pre-fence binary를 0으로 만들고 no-dual-writer probe 확인; +9. `LEGACY_POLLING` epoch와 legacy relay를 계속 ACTIVE로 유지; +10. P3 ACK-aware producer/new relay/operator tool이 scheduler-disabled 상태로 배포·attest되기 전 + `POLLING_V2` authority 전환과 v2 claim을 금지. + +P2는 schema/control-plane candidate일 뿐 publish path cutover가 아니다. + +live non-empty deployment는 이 base card를 통과시켜 자동 backfill하지 않는다. +row volume, active state distribution, lock/replication budget, data classification, maintenance +window와 rollback rehearsal을 입력으로 다음 중 하나를 **별도 deployment migration design과 +승인 gate**에서 고른다. + +```text +LIVE_ADDITIVE_BACKFILL_IN_PLACE.v1 +COPY_AND_CUTOVER_WITH_RECONCILIATION.v1 +``` + +두 live card 모두 이 문서의 stable event identity, legacy ACK non-overclaim, relay authority fence, +same-transaction/no-dual-write invariant를 따라야 하지만 어느 것이 안전한지는 실제 database +evidence 없이 base template가 추측하지 않는다. 따라서 이 선택은 구현 계획에 숨겨 둔 선택이 +아니라 명시적으로 차단된 deployment-specific gate다. + +모든 migration card는: + +- dual-write gap 없음; +- same transaction; +- rollback; +- old/new binary compatibility; +- relay authority가 어떤 순간에도 정확히 하나; +- legacy/v2 query generation과 backlog handoff watermark; +- no event identity change; +- backup/rollback + +을 증명한다. + + + +### 30.4 Legacy event migration + +현재 pending row는 hand-written envelope/eventType/raw payload다. + +base template default: + +- verified empty/drained V3이면 legacy payload transform 없이 additive migration; +- drain/reset은 production data 삭제를 뜻하지 않으며 non-production 또는 승인된 maintenance + 범위만 대상으로 evidence를 남김. + +live-data deployment gate: + +- live data면 `legacy-envelope-v0` read-only publisher; +- known contract는 승인된 live migration card에서만 v1로 deterministic transform하되 original + hash/audit 보존; +- unknown contract는 자동 topic publish하지 않고 operator quarantine. + +이미 발행된 event의 wire contract를 몰래 바꾸지 않는다. + +현재 `PUBLISHED`는 broker ACK가 아니라 `void KafkaSender` 정상 반환이다. migration은 +`ackObservedAt`, provider metadata 또는 `DELIVERY_RECORDED`를 조작해 채우지 않는다. 운영자는 +event 범위별로: + +- downstream reconciliation 뒤 legacy terminal로 수용; +- duplicate-aware requeue; +- quarantine/hold + +중 하나를 audit한다. legacy `IN_FLIGHT`는 old relay fence와 drain/expiry 전 변환하지 않는다. + +final fenced reconciliation의 기본 상태 매핑은 다음과 같다. + +```text +legacy PENDING -> READY +legacy FAILED -> RETRY_WAIT (preserved finite due/budget, 없으면 reviewed DB-time due) +legacy DEAD -> EXHAUSTED (acceptance certainty를 fabricated definite rejection으로 만들지 않음) +legacy PUBLISHED -> LEGACY_RECORDED_UNVERIFIED +legacy IN_FLIGHT -> HOLD + remaining-indeterminate audit +``` + +이미 provisional delivery가 있으면 final legacy state와 expected row version을 대조해 같은 current +generation을 migration-only CAS로 맞추고, 없으면 정확히 하나를 insert한다. duplicate CURRENT, +unknown status/contract, event/hash mismatch는 자동 추측하지 않고 cutover transaction을 +rollback한다. 이 매핑은 migration에서만 허용되며 정상 v2 worker state machine을 우회하는 일반 +운영 API가 아니다. + +### 30.5 Phase 3 — Spring Kafka producer and polling reference path + +구현: + +- Spring Kafka dependency/lock; +- explicit producer factory/template; +- typed provider settings/compiler; +- ACK-aware gateway/outcome; +- topic attestation; +- finite producer/admission/deadline; +- polling relay outcome/state update; +- late-completion source/drain/attempt observation; +- application disposition use case + authenticated inbound-web operator endpoint; +- producer generation/lifecycle; +- best-effort migration; +- config/env/README/runbook update; +- real Kafka/PostgreSQL happy/failure tests. + +P3 cutover는 다음 순서를 고정한다. + +1. ACK-aware producer와 v2 relay를 scheduler-disabled로 배포; +2. contract/catalog/schema, producer/topic/security와 transaction-resource preflight; +3. operator disposition endpoint의 auth/CAS/audit negative test; +4. bounded write maintenance를 시작해 신규 business transaction admission을 막고 active writer와 + ACTIVE epoch `FOR SHARE` holder가 0이 될 때까지 drain; +5. old legacy relay 신규 claim 중단, IN_FLIGHT maximum budget drain, remaining indeterminate audit; +6. old producer close/Write fence와 DB legacy mutation guard 활성; +7. 하나의 cutover DB transaction을 열어 ACTIVE legacy epoch를 `FOR UPDATE`로 잠그고, writes와 + legacy mutation이 fenced된 snapshot에서 fixed legacy handoff watermark를 기록; +8. 같은 transaction에서 pre-backfill 뒤 watermark까지 생긴 delta를 포함해 모든 legacy event를 + final reconcile한다. 각 row는 final legacy state에 대응하는 정확히 한 CURRENT v2 delivery + (`READY/RETRY_WAIT/EXHAUSTED/HOLD/LEGACY_RECORDED_UNVERIFIED`)를 가지며 active claim은 0이어야 + 한다. row count, event ID/hash manifest와 unmapped/duplicate count 0을 assertion; +9. 같은 transaction에서 `LEGACY_POLLING -> POLLING_V2` ACTIVE epoch 전환과 v2 cutover sentinel + event+delivery INSERT 뒤 commit. reconciliation/manifest/switch 중 하나라도 실패하면 전체 + rollback; +10. v2 relay만 start하고 claim token/sequence/valid-lease CAS 사용; +11. canonical append path가 만든 v2 sentinel ACK/`DELIVERY_RECORDED`, legacy writer 0, + no-dual-send와 fresh readiness를 확인한 뒤 expected FROZEN generation/epoch/evidence CAS로 + write admission을 `OPEN(generation+1)`하고 business writes 재개; +12. rollback window 뒤 obsolete legacy columns 제거는 별도 later forward migration. + +write maintenance는 process-local boolean이 아니다. 모든 `TransactionPort.inWrite`는 같은 +transaction에서 PostgreSQL admission singleton을 `FOR KEY SHARE`로 잡고 OPEN/fence generation을 +확인한다. freeze transaction은 그 row를 `FOR UPDATE`로 잡아 기존 share holder가 commit/rollback할 +때까지 기다린 뒤 FROZEN generation을 durable하게 기록한다. 새 writer는 그 뒤 fail-closed +rollback한다. live node lease와 deployment instance inventory가 expected source/fence protocol로 +일치하지 않거나 legacy relay/reaper/producer Write의 zero-active/negative-Write probe가 없으면 +one-shot precondition evidence를 만들지 않는다. + +epoch commit 뒤에도 admission은 자동으로 열리지 않는다. v2 sentinel이 +`DELIVERY_RECORDED`이고, frozen 상태에서 maintenance-only canonical append canary가 exact +envelope/projection/delivery를 만들고, topic/security/readiness가 fresh한 경우에만 application +resume use case가 expected FROZEN generation + `POLLING_V2` epoch + evidence digest CAS로 +`OPEN(generation+1)`을 기록한다. ordinary `TransactionPort.inWrite`는 그 전까지 계속 거부된다. +resume mismatch/replay/failure는 FROZEN을 유지한다. epoch commit 뒤 runner가 실패하면 별도 +`resume-polling-v2-writes` one-shot recovery operation만 같은 증거를 다시 검증할 수 있고 raw SQL +status update는 금지한다. + +cutover는 authenticated public/web endpoint가 아니라 non-web one-shot maintenance runner가 +opaque approval evidence ID와 expected target/source/epoch를 받아 수행한다. operation ID와 +evidence consumption은 DB에서 재실행을 막고, 실패는 non-zero exit와 immutable audit를 남긴다. +epoch commit 전 실패는 DB transaction rollback만으로 끝내지 않는다. 먼저 모든 fence를 유지한 +채 fresh evidence/approval deadline 안에서 bounded forward retry할 수 있다. abort-to-legacy를 +선택하면 exact epoch가 여전히 `LEGACY_POLLING`이고 v2 business send/sentinel authority가 없으며 +inventory가 보존됐음을 확인한다. 외부 ACL을 바꾸기 전에 DB에서 exact cutover attempt를 잠그고 +`CUTOVER_PENDING -> RECOVERING_LEGACY`로 CAS하면서 recovery operation/lease/evidence digest를 +결합한다. 이 전이는 같은 attempt를 모든 forward finalizer에서 원자적으로 무효화하며, lease가 +만료돼도 `CUTOVER_PENDING`으로 돌아가지 않고 recovery-only takeover만 허용한다. 반대로 +finalizer는 epoch transaction 안에서 `CUTOVER_PENDING -> FINALIZING_V2`를 먼저 CAS하고 +성공 commit에서만 `CONSUMED_V2`로 바꾼다. 따라서 두 분기는 동일 attempt에서 함께 진행될 수 없다. + +상호 배제 범위는 attempt 하나가 아니라 outbox authority 전체다. 모든 attempt는 상수 +`OUTBOX_PUBLICATION` authority scope, ACTIVE legacy epoch, FROZEN fence generation과 target binding을 +저장하고, partial unique constraint는 이 scope에 nonterminal +`CUTOVER_PENDING|FINALIZING_V2|RECOVERING_LEGACY` row를 정확히 하나만 허용한다. evidence 생성, +finalization, recovery prepare/completion은 모두 write-admission singleton `FOR UPDATE` 뒤 ACTIVE +epoch `FOR UPDATE`의 동일 lock order를 사용하고 exact generation/target/sole-attempt를 검증한다. +따라서 recovery 중 별도 attempt를 만들어 v2 epoch를 commit할 수 없다. 같은 attempt뿐 아니라 +서로 다른 attempt의 생성/finalization과 recovery 사이 양방향 경쟁도 real PostgreSQL 시험으로 +고정한다. + +recovery claim이 commit된 뒤에만 외부 provisioning이 legacy principal Write를 재부여하고 fresh +positive probe를 만든다. 그 다음 exact attempt/owner/lease와 ACTIVE +`LEGACY_POLLING` epoch를 다시 잠가 검증한다. 불일치하면 legacy Write를 즉시 다시 revoke하고 fresh +negative probe를 만든 뒤 business writes를 FROZEN으로 유지한다. 검증이 성공하면 immutable +recovery/ACL audit 아래 in-process legacy Write fence, reaper, relay를 generation-CAS로 다시 열고 +마지막에 write admission을 `OPEN(generation+1)`로 CAS하면서 attempt를 +`RECOVERED_LEGACY`로 끝낸다. 어느 단계든 실패하면 business writes는 FROZEN을 유지하고 이미 연 +legacy component를 다시 fence하거나 safe degraded state로 둔 채 recovery-only 재시도한다. +forward-finalization 대 recovery-claim의 양방향 lock-order 경쟁과 각 외부 mutation/crash +경계를 real PostgreSQL 계약 시험으로 고정한다. raw SQL이나 단순 boolean toggle은 금지한다. + +epoch commit 뒤에는 business v2 send 여부와 무관하게 reverse epoch/legacy reactivation을 first +R2에서 지원하지 않는다. admission을 닫고 backlog/schema/epoch/audit를 보존한 채 forward-fix한다. + +qualification fixture의 broker/principal은 `qualificationEnvironmentIdentity`, 실제 target은 +`deploymentBindingIdentity`로 별도 기록한다. 공통 비교 대상은 capability/profile, supported +broker/client version constraint, settings/catalog/schema와 scenario contract다. 실제 cutover는 +target cluster/topic/principal/secret generation에 대한 fresh topology/security/ACL attestation과 +legacy principal negative-Write probe를 별도 요구하며 fixture identity를 target evidence로 +재사용하지 않는다. + +large live-data card는 bulk pre-backfill을 별도 bounded batch로 수행할 수 있지만, step 7 +transaction 안의 fixed watermark, final delta, manifest assertion과 authority switch는 한 fenced +atomic unit에 남긴다. 그 transaction의 lock/statement/replication budget이 evidence로 안전하지 +않으면 `COPY_AND_CUTOVER_WITH_RECONCILIATION.v1` 또는 더 긴 maintenance를 다시 승인하며 부분 +authority switch를 허용하지 않는다. + +이 phase 끝은 R1/R2 candidate다. security/fault/release evidence 없이 R2 완료가 아니다. + +### 30.6 Phase 4 — R2 qualification and rollout + +- production TLS/SASL_SSL; +- least-privilege ACL; +- topic topology policy; +- selected RF/min ISR multi-broker failure/recovery; +- secret/certificate rotation; +- fault/crash/late ACK/stale token; +- multi-worker/capacity/shutdown; +- observability/readiness/card registry; +- no-skip CI; +- sanitized evidence artifact; +- canary/rollback rehearsal; +- runbook completion. + +exact first tuple만 R2로 승격한다. + +### 30.7 Phase 5 — Inbound Kafka and inbox + +- registry 19 -> 20 migration; +- inbound listener leaf; +- consumer contract/catalog decoder; +- manual ACK/bounded processing/rebalance; +- application `MessageConsumptionExecutor`; +- PostgreSQL inbox; +- DLT/replay tooling; +- security/observability; +- real Kafka/PostgreSQL consumer evidence. + +producer/polling R2와 별도 card/status다. + +### 30.8 Phase 6 — CDC + +- insert-only source enforcement; +- deployment connector/slot/publication/internal topics; +- exact Debezium mapping; +- shadow topic; +- fault/WAL/offset/failover; +- cutover/rollback rehearsal; +- CDC retention proof; +- separate readiness/evidence. + +polling R2를 제거하지 않는다. deployment가 둘 중 하나를 선택하되 같은 production destination에서 +동시 활성화하지 않는다. + +### 30.9 Phase 7 — Optional cards + +실제 요구와 evidence가 있을 때: + +- retry topic; +- Kafka EOS; +- schema registry; +- Avro/Protobuf; +- compaction; +- claim-check/large message; +- multi-cluster; +- alternate provider; +- module/artifact split. + +### 30.10 Rollout + +first polling rollout: + +1. selected migration card preflight와 forward schema migration; +2. compatibility append + legacy relay-fence binary; +3. contract/catalog/schema artifact; +4. provisional delivery backfill/legacy unverified reconciliation; +5. ACK-aware producer + v2 relay의 **disabled** canary startup; +6. producer/topic/security/transaction-resource/operator-tool preflight; +7. business write admission freeze + active writer drain; +8. old relay stop, IN_FLIGHT drain, producer Write/DB legacy mutation fence; +9. 한 fenced DB transaction에서 ACTIVE epoch lock + fixed handoff watermark + final delta + reconciliation + count/hash manifest assertion; +10. 같은 transaction에서 atomic `LEGACY_POLLING -> POLLING_V2` authority switch + v2 sentinel + insert 뒤 commit; +11. v2 relay start와 sentinel ACK/`DELIVERY_RECORDED`, legacy writer 0 확인 뒤 writes 재개; +12. bounded subset/destination enable; +13. backlog/duplicate/indeterminate/no-dual-authority observation; +14. full enable; +15. rollback window 뒤 legacy seam/config removal. + +rollback은 DB schema를 destructive downgrade하지 않는다. authority epoch, producer Write ACL과 +relay fence를 먼저 판정하고 new relay disable/backlog preservation을 사용한다. 이미 v2 record가 +Kafka에 갈 수 있는 시점부터 old/new relay를 동시에 켜는 rollback은 금지한다. + +### 30.11 Status update discipline + +각 phase가 끝날 때 §0에: + +- implemented capability; +- not implemented; +- evidence/card level; +- exact test command/result; +- known limitation; +- next optional candidates + +를 갱신한다. 본문 설계 문장을 “구현됨”으로 다시 쓰지 않는다. + +## 31. Completion criteria and extension ledger + +### 31.1 Design complete + +설계 완료 조건: + +- 사용자 review/approval; +- architecture/module ownership 확정; +- first tuple 확정; +- producer/outbox/consumer/CDC guarantee와 non-guarantee 명시; +- base template migration card 확정과 live-data deployment approval gate 명시; +- migration/test/runbook/evidence 계획; +- unresolved decision이 implementation plan에 숨지 않음; +- LLM Wiki capture. + +현재 문서 상태는 사용자 승인에 따라 +`상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수`다. + +### 31.2 First R2 implementation complete + +정본 완료 판정은 §10.5 machine registry의 exact selected tuple이 모두 +`release-eligible`이고 required scenario/evidence fingerprint가 PASS인 경우뿐이다. 아래는 drift를 +막기 위한 human review checklist다. + +1. raw event type -> topic 제거; +2. closed contract/destination catalog; +3. envelope/payload schema v1; +4. append-before-persist validation; +5. immutable event + delivery split; +6. aggregate sequence + normal-path key order/non-guarantee; +7. claim token + unexpired-lease CAS와 JIT claim; +8. ACK-aware Spring Kafka producer; +9. ACK/ACK-mismatch/REJECTED/INDETERMINATE와 attempt journal; +10. legal late-completion observation drain과 bounded-loss semantics; +11. authenticated operator disposition use case/control surface; +12. finite effective config; +13. combined retry/requeue horizon budget; +14. production security/ACL/topic conformance; +15. multi-broker RF/min ISR failure evidence; +16. disabled resource 0; +17. bounded shutdown/rotation; +18. real Kafka/PostgreSQL/fault/security tests; +19. no-skip release gate; +20. descriptor/card/evidence artifact; +21. runbook; +22. legacy drift/migration; +23. Wiki capture. + +### 31.3 Consumer/inbox complete + +별도 조건: + +- inbound leaf/registry; +- manual ACK after commit; +- inbox + business same transaction; +- duplicate/collision behavior; +- bounded poll/backpressure/rebalance; +- DLT ACK ordering; +- replay audit; +- security/retention; +- real fault evidence. + +producer R2만으로 이 조건을 충족했다고 표시하지 않는다. + +### 31.4 CDC complete + +별도 조건: + +- insert-only source; +- exact connector mapping; +- slot/offset/WAL/security; +- duplicate/restart/failover; +- shadow/cutover/rollback; +- authority exclusivity; +- retention proof; +- real multi-service evidence. + +event/delivery table split만으로 CDC ready라고 표시하지 않는다. + +### 31.5 추가 가능한 card + +| 추가 요구 | 추가 card/설계 | 안정적으로 유지할 것 | +| --- | --- | --- | +| consumer | inbound Kafka + inbox | event ID, envelope, logical destination | +| CDC | Debezium dispatch | immutable event, key/wire semantics | +| binary schema | Avro/Protobuf registry | contract ID, payload version/outcome | +| delayed retry | retry topic | event ID/inbox, explicit ordering loss | +| Kafka-only workflow | transactional EOS | DB/non-Kafka scope 제외 | +| large payload | object-storage claim check | event identity/schema/security | +| alternate broker | provider card | semantic contract/evidence rule | +| multi-cluster | replication/failover card | no global ordering/exactly-once overclaim | +| topic compaction | contract-specific compacted log | key/tombstone/replay semantics | + +outbound messaging leaf 분리는 다음 trigger 전에는 하지 않는다. + +- 두 번째 broker provider가 독립 dependency/release lifecycle을 가짐; +- codec/schema runtime을 inbound leaf도 재사용해야 하지만 `shared-contract` purity로 수용할 수 + 없음; +- AdminClient/topology attestor가 독립 deployment artifact가 됨; +- producer와 compiler의 dependency/security ownership이 별도 release를 요구함. + +trigger가 생기면 registry leaf/edge migration과 동일 semantic card/evidence compatibility를 +함께 설계한다. package가 많다는 이유만으로 선제 분리하지 않는다. + +### 31.6 금지하는 완료 표현 + +다음 표현은 조건 없이 사용하지 않는다. + +- “Kafka 지원 완료” — seam인지 real selected card인지 명시; +- “broker ACK” — actual future metadata evidence 필요; +- “exactly once” — boundary를 좁힌 Kafka EOS card 외 금지; +- “중복 안전” — 해당 consumer/inbox evidence 필요; +- “strict FIFO” — sequence/key/topology뿐 아니라 failure/rotation/consumer order gate evidence 필요; +- “CDC ready” — connector/slot/offset/cutover evidence 필요; +- “DLT 처리 완료” — DLT publish와 business remediation 구분; +- “security ready” — TLS/SASL/ACL/rotation negative test 필요; +- “production ready” — exact R2 card/evidence 필요; +- “test passed” — command/result/skip을 기록. + +### 31.7 구현 보고 template + +후속 구현 완료 보고는 최소 다음을 포함한다. + +```text +이번에 구현된 card/phase +변경 파일 +architecture/dependency 변화 +실행한 focused/real-service/common gate +test count/result/skip +evidence fingerprint/artifact +아직 미구현인 card +현재 보장과 non-guarantee +runbook +LLM Wiki capture +남은 위험 +``` + +## 32. Required runbooks + +### 32.1 First R2 baseline + +1. `producer-unavailable-or-unauthorized` + - DNS/network/TLS/SASL/ACL/topic/min ISR 분기; + - direct/relay/write role별 guarantee 영향; + - safe pause/recovery proof. +2. `outbox-backlog-and-stale-lease` + - oldest age/count/growth; + - claim owner/token/lease conflict; + - hot/stuck aggregate; + - capacity/scale 한계. +3. `delivery-indeterminate-and-duplicate-burst` + - ACK loss/late ACK/mark failure; + - suspected event ID range; + - downstream inbox/reconciliation; + - resend duplicate 경고. +4. `schema-poison-or-record-too-large` + - contract/version/byte diagnosis; + - retry 중단; + - corrected/compensating event; + - payload 원문 log 금지. +5. `terminal-delivery-disposition` + - hold/requeue/skip/compensate; + - business-owner 승인; + - aggregate 후행 영향; + - audit/rollback. +6. `topic-policy-or-partition-change` + - topic drift; + - partition expansion order risk; + - new topic migration/cutback. +7. `shutdown-deploy-and-secret-rotation` + - new claim stop/drain; + - cert/secret expiry; + - old/new generation; + - forced timeout/rollback. +8. `legacy-to-v2-relay-authority-cutover` + - compatibility binary/fingerprint; + - legacy claim stop/IN_FLIGHT drain; + - DB epoch/legacy mutation fence; + - backlog watermark/no-dual-send probe; + - rollback stop condition. + +### 32.2 Future consumer + +- consumer lag/max.poll/rebalance loop; +- retry exhaustion/DLT publish failure; +- DLT duplicate identity/quarantine capacity/strict-order HOLD; +- schema/deserialization poison; +- inbox collision/retention; +- inbox purge vs live/replay effect-generation lease; +- audited replay; +- offset out of range; +- consumer identity/group migration; +- shutdown with active handler. + +### 32.3 Future CDC + +- connector/task down; +- WAL growth/disk pressure; +- slot missing/ahead/behind; +- offset reset/alter; +- snapshot restart/schema drift; +- PostgreSQL primary failover; +- polling -> CDC cutover; +- CDC -> polling rollback; +- shadow/production connector와 unique slot ownership; +- cutover 단계별 abort/authority proof; +- PostgreSQL 16 failover DATA_GAP_SUSPECTED recovery; +- WAL emergency without automatic slot drop; +- CDC retention/partition purge. + +### 32.4 Common structure + +모든 runbook: + +```text +detection/trigger +blast radius +current guarantee degradation +safe first response +diagnosis evidence +non-destructive mitigation +destructive action approval boundary +reconciliation +recovery proof +rollback +audit/post-incident +related metrics/errors/cards +``` + +### 32.5 Existing runbook migration + +`outbox-publish-failed.md`와 `outbox-dead-letter.md`는 현재 stub다. first implementation에서: + +- removed `APP_MESSAGING_KAFKA_ENABLED` 제거; +- 실제 class/settings/table/state 이름; +- producer EXHAUSTED와 consumer DLT 구분; +- “consumer dedupe가 현재 안전 보장” 표현 제거; +- raw SQL status rewrite 제거; +- token/generation/audit operator tool; +- real dashboard/alert link; +- split event/delivery query; +- indeterminate/duplicate 절차 + +로 갱신한다. + +## 33. Primary references + +구현은 실제 BOM/lock에 resolve된 version 문서를 우선한다. 아래는 설계 시 확인한 official primary +reference다. + +### Spring Boot and Spring Kafka + +- [Spring Boot 4.0 — Apache Kafka Support](https://docs.spring.io/spring-boot/4.0/reference/messaging/kafka.html) +- [Spring Kafka 4.0 — Sending Messages](https://docs.spring.io/spring-kafka/reference/4.0/kafka/sending-messages.html) +- [Spring Kafka 4.0 — Message Listener Containers](https://docs.spring.io/spring-kafka/reference/4.0/kafka/receiving-messages/message-listener-container.html) +- [Spring Kafka 4.0 — Pausing and Resuming Listener Containers](https://docs.spring.io/spring-kafka/reference/4.0/kafka/pause-resume.html) +- [Spring Kafka 4.0 — Handling Exceptions](https://docs.spring.io/spring-kafka/reference/4.0/kafka/annotation-error-handling.html) +- [Spring Kafka 4.0 — DefaultErrorHandler API](https://docs.spring.io/spring-kafka/docs/4.0.x/api/org/springframework/kafka/listener/DefaultErrorHandler.html) +- [Spring Kafka 4.0 — Retry Topic Pattern](https://docs.spring.io/spring-kafka/reference/4.0/retrytopic/how-the-pattern-works.html) +- [Spring Kafka 4.0 — Transactions](https://docs.spring.io/spring-kafka/reference/4.0/kafka/transactions.html) +- [Spring Kafka 4.0 — Exactly Once Semantics](https://docs.spring.io/spring-kafka/reference/4.0/kafka/exactly-once.html) +- [Spring Kafka 4.0 — Monitoring](https://docs.spring.io/spring-kafka/reference/4.0/kafka/micrometer.html) +- [Spring Kafka 4.0 — Testing Applications](https://docs.spring.io/spring-kafka/reference/4.0/testing.html) + +### Apache Kafka + +- [Kafka 4.1 Producer Configs](https://kafka.apache.org/41/configuration/producer-configs/) +- [Kafka 4.1 Topic Configs](https://kafka.apache.org/41/configuration/topic-configs/) +- [Kafka 4.1 Consumer Configs](https://kafka.apache.org/41/configuration/consumer-configs/) +- [Kafka 4.1 Design — Message Delivery Semantics](https://kafka.apache.org/41/design/design/#message-delivery-semantics) +- [Kafka 4.1 Security Overview](https://kafka.apache.org/41/security/security-overview/) +- [Kafka 4.1 Kafka Connect User Guide](https://kafka.apache.org/41/kafka-connect/user-guide/) +- [Kafka 4.1 Kafka Connect Configs](https://kafka.apache.org/41/configuration/kafka-connect-configs/) +- [Kafka 4.1 KafkaProducer API](https://kafka.apache.org/41/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html) +- [Kafka 4.1 UnknownTopicOrPartitionException](https://kafka.apache.org/41/javadoc/org/apache/kafka/common/errors/UnknownTopicOrPartitionException.html) +- [Kafka 4.1 NotEnoughReplicasAfterAppendException](https://kafka.apache.org/41/javadoc/org/apache/kafka/common/errors/NotEnoughReplicasAfterAppendException.html) + +### JSON and trace contract + +- [JSON Schema Draft 2020-12](https://json-schema.org/draft/2020-12) +- [JSON Schema Core 2020-12](https://json-schema.org/draft/2020-12/json-schema-core) +- [JSON Schema Validation 2020-12](https://json-schema.org/draft/2020-12/json-schema-validation) +- [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) +- [RFC 8259 — The JavaScript Object Notation Data Interchange Format](https://www.rfc-editor.org/rfc/rfc8259) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) + +### Debezium, Kafka Connect and PostgreSQL CDC + +- [Debezium 3.6 Release Series](https://debezium.io/releases/3.6/) +- [Debezium 3.6 Outbox Event Router](https://debezium.io/documentation/reference/3.6/transformations/outbox-event-router.html) +- [Debezium 3.6 PostgreSQL Connector](https://debezium.io/documentation/reference/3.6/connectors/postgresql.html) +- [PostgreSQL 16 Logical Decoding Concepts](https://www.postgresql.org/docs/16/logicaldecoding-explanation.html) +- [PostgreSQL 16 Logical Replication Security](https://www.postgresql.org/docs/16/logical-replication-security.html) +- [PostgreSQL 16 CREATE PUBLICATION](https://www.postgresql.org/docs/16/sql-createpublication.html) +- [PostgreSQL 16 Logical Replication Row Filters](https://www.postgresql.org/docs/16/logical-replication-row-filter.html) +- [PostgreSQL 16 INSERT / ON CONFLICT](https://www.postgresql.org/docs/16/sql-insert.html) +- [PostgreSQL 16 Unique Constraints](https://www.postgresql.org/docs/16/ddl-constraints.html) + +### Test infrastructure + +- [Testcontainers for Java — Kafka Module](https://java.testcontainers.org/modules/kafka/) +- [Testcontainers for Java — PostgreSQL Module](https://java.testcontainers.org/modules/databases/postgres/) + +문서 링크는 executable evidence가 아니다. implementation 시 exact dependency/image version, +effective config, tests와 runbook으로 다시 확인한다. + +## 34. Review 이후 다음 단계 + +base template의 핵심 architecture option과 empty/drained V3 기본 migration card는 확정됐다. +사용자에게 구현 세부 선택을 더 요구하지 않는다. 다만 실제 live non-empty database에 적용하는 +시점에는 §30.3의 row/lock/data evidence를 수집한 뒤 live migration card를 별도 승인해야 한다. +그것은 지금 숨겨 둔 선택이 아니라 deployment-specific safety gate다. review에서 방향 수정이 +없으면 다음 순서로 진행한다. + +1. P0–P4 first R2 실행 계획과 독립 review 완료; +2. `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 계획 실행; +3. test-first로 contract/schema/polling/producer를 단계 구현; +4. exact first tuple R2 evidence 뒤 consumer/inbox 계획; +5. consumer evidence 뒤 CDC qualification/cutover 계획. + +repository의 human-only commit 정책에 따라 agent는 stage/commit/amend/push하지 않는다. diff --git a/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md b/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md new file mode 100644 index 0000000..7917eb1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md @@ -0,0 +1,4860 @@ +# Notification Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: 상세 설계 승인, 구현 계획 작성, 구현 미착수 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-notification` +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- 비교 기준: + [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md), + [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md), + [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) + +## 0. 문서 상태와 구현 상태 + +이 문서는 Notification capability의 승인된 상세 설계다. 2026-07-28에 §36의 다섯 gate를 +사용자가 승인했으며, 실제 구현 순서는 +[Notification Production Capability Implementation Plan](../plans/2026-07-28-notification-production-capability.md)을 +정본으로 사용한다. 구현 및 production readiness는 아직 주장하지 않는다. + +2026-07-28 현재 구현된 범위: + +- `application-core`의 `Channel`, raw `Notification`, `NotificationPort`; +- `(channel, route) -> providerId list` fan-out router; +- channel 안의 중복 provider ID 및 configured route가 존재하지 않는 provider ID를 참조하는 + 경우의 construction 검증; +- route 미설정 시 silent no-op 대신 `AdapterDisabledException`; +- provider exception을 기록하되 호출자에게 전파하지 않는 global fail-open decorator; +- PII인 recipient와 body를 dependency log에 넣지 않는 단위 테스트; +- `google-email`, `slack-webhook` provider/client extension seam; +- provider/client fake를 이용한 routing, fan-out, fail-open 단위 테스트. + +아직 구현되지 않은 범위: + +- 실제 Google email 또는 Slack client; +- versioned template, locale, typed parameter schema와 rendering; +- best-effort와 durable delivery mode의 명시적 분리; +- provider-neutral submission outcome과 recipient outcome; +- durable notification intent/delivery/attempt/receipt store; +- claim owner token, retry horizon, expiry, reconciliation과 unknown outcome; +- consent, preference, quiet-hours와 business suppression; +- hard bounce, complaint와 technical suppression; +- provider별 quota, rate limit, retry, idempotency 및 receipt capability; +- canonical binding/expected-state 설정과 zero-resource 비활성 계약; +- Slack Web API와 Amazon SES v2 reference provider; +- provider callback verification 및 deduplication; +- production readiness lane과 real-provider evidence. + +현재 코드는 R0 extension seam과 local routing skeleton이다. 테스트가 통과하더라도 email 또는 +Slack notification이 실제로 전송된다는 증거가 아니며, durable/critical notification의 +근거도 아니다. + +## 1. 설계 판정 + +현재 Notification 구현의 가장 큰 문제는 provider가 없다는 사실만이 아니다. 다음 의미가 +하나의 `void notify(...)` 호출에 섞여 있다. + +1. 업무상 알림을 만들어도 되는가; +2. 어떤 template과 locale을 쓸 것인가; +3. inline으로 시도할 것인가 durable하게 저장할 것인가; +4. 어떤 provider에 몇 번 시도할 것인가; +5. provider가 요청을 받았는가; +6. recipient system까지 도착했는가; +7. 실패를 무시해도 되는가; +8. 응답을 잃었을 때 재전송해도 되는가. + +현재 global fail-open은 provider가 던진 모든 예외를 삼키므로 호출자는 성공, 실패, +indeterminate를 구분할 수 없다. 반대로 route list는 항상 fan-out으로 해석되어 ordered +fallback과 single provider가 구분되지 않는다. raw recipient/subject/body는 template +version, locale, parameter schema, idempotency, expiry, consent snapshot을 표현하지 못한다. + +이번 설계의 목표는 범용 `send(channel, recipient, body)` SDK가 아니다. + +> feature-specific application policy가 생성한 versioned notification intent를, 검토된 +> route와 template에 따라 bounded하게 계획하고, best-effort inline 또는 durable async +> mode로 실행하며, provider submission과 recipient outcome을 분리해 추적·재시도·복구하는 +> outbound capability + +선택한 핵심 구조는 다음과 같다. + +1. Business use case는 `PasswordResetNotificationRequestFactory` 같은 feature-specific + application policy/factory와 명시적 outbound port를 사용한다. +2. `application-core`에는 framework-free notification intent, plan, append/store와 dispatch + use case 계약만 둔다. +3. `adapter-outbound-notification`은 route/template catalog, rendering, provider attempt와 + provider-specific reconciliation을 소유한다. +4. business consent, preference, quiet-hours, notification 필요성은 domain/application이 + 소유한다. +5. technical bounce/complaint suppression은 notification lifecycle의 기술 상태로 분리한다. +6. `BEST_EFFORT_INLINE`과 `DURABLE_ASYNC`를 별도 계약으로 두며 application code의 + `NotificationKindPolicy`만 mode를 결정한다. +7. durable mode는 source business DB와 같은 transaction에서 recipient 1명의 intent를 append하고, + 별도 dispatcher가 short claim transaction 뒤 DB transaction 밖에서 provider를 호출한다. +8. intent, provider delivery leg, physical attempt, provider receipt를 서로 다른 identity/state로 + 관리한다. +9. timeout이나 ACK loss 뒤의 결과는 일반 retryable failure가 아니라 `INDETERMINATE`로 + 모델링한다. +10. 외부 provider 호출과 local DB commit 사이의 exactly-once는 주장하지 않는다. +11. Slack 초기 R2 reference provider는 Web API `chat.postMessage`, email은 Amazon SES v2 + API로 선택한다. +12. 기존 `slack-webhook`과 `google-email` seam은 legacy R0 best-effort compatibility로만 + 취급하며 durable/critical/receipt-required route에 binding하지 않는다. +13. binding이 없으면 provider client, scheduler, callback subscription, health probe를 만들지 + 않는다. +14. provider별 production readiness는 정확한 effective capability tuple과 real-provider + evidence로 판정한다. + +## 2. 기존 심화 설계에서 재사용할 패턴과 재사용하지 않을 패턴 + +### 2.1 재사용할 공통 패턴 + +| 기존 설계 | Notification에 재사용할 결정 | +| --- | --- | +| Redis | semantic port, capability별 failure policy, typed activation, exact readiness card, bounded resource | +| Fileserver | provider-neutral request/receipt, configured guarantee와 achieved guarantee 분리, `INDETERMINATE`, reconciliation 우선 | +| HTTP Client | typed ID/catalog, expected-state binding, logical call과 physical attempt 분리, retry amplification budget, PII-safe telemetry | + +세 문서에서 공통으로 채택한 다음 원칙도 그대로 적용한다. + +- application에 provider SDK나 transport 타입을 노출하지 않는다; +- arbitrary provider/endpoint/credential을 caller가 선택하지 않는다; +- code catalog가 허용한 operation/template/route만 config가 활성화한다; +- config는 code에 검토된 상한을 강화할 수 있지만 완화할 수 없다; +- no binding은 zero side effect다; +- timeout과 response loss는 성공/실패 이분법으로 축소하지 않는다; +- fake test만으로 production provider readiness를 주장하지 않는다; +- metric tag에 high-cardinality 또는 PII 값을 쓰지 않는다; +- legacy alias와 canonical 설정이 동시에 존재하면 precedence를 추론하지 않고 실패한다. + +### 2.2 Notification에 복사하지 않을 capability-specific 패턴 + +- Redis key/hash slot, Lua/Function, topology semantics를 notification dedupe나 lock에 + 재사용하지 않는다. +- Fileserver의 staging/rename/journal을 notification delivery journal에 그대로 투영하지 + 않는다. +- HTTP method idempotency나 URI/DNS/pool 정책을 provider-neutral notification 의미로 + 노출하지 않는다. +- application outbox의 현재 generic row/publisher를 곧바로 notification delivery store로 + 간주하지 않는다. +- broker publish 성공을 recipient delivery로 간주하지 않는다. +- Slack message timestamp나 SES message ID를 application-wide idempotency key로 사용하지 + 않는다. + +### 2.3 Normative decision ledger + +긴 문서에서 결정을 다시 추론하지 않도록 구현과 리뷰는 다음 위치를 정본으로 사용한다. + +| 결정 | 정본 | +| --- | --- | +| capability/readiness 용어 | §7 | +| 모듈 소유권과 의존성 | §8, §31 | +| application 계약과 typed 값 | §9–§10 | +| mode와 state machine | §11–§12 | +| planning/routing/fan-out/fallback | §13 | +| template/rendering/localization | §14 | +| provider attempt와 retry 의미 | §15 | +| durable DB workflow와 concurrency | §16 | +| Slack/Email reference provider | §18–§19 | +| receipt/reconciliation/suppression | §20 | +| 설정·activation·zero-resource | §21–§22 | +| deadline/resource/amplification | §23 | +| 보안·개인정보·보존 | §24 | +| 관측성·health·lifecycle | §25–§27 | +| 테스트·CI·evidence | §29 | +| migration과 completion | §32–§34 | + +예시 YAML, Java shape 또는 migration alias가 이 표의 정본보다 우선하지 않는다. + +## 3. 증거 기반 현재 상태 + +### 3.1 application contract가 delivery 의미를 표현하지 못한다 + +현재 application contract는 다음 세 타입뿐이다. + +```text +Channel = EMAIL | SLACK +Notification = recipient + subject + body +NotificationPort.notify(channel, route, notification) -> void +``` + +이 계약에는 다음 필드가 없다. + +- intent ID와 idempotency/fingerprint; +- feature/notification kind; +- template ID/version과 locale; +- typed template parameter; +- delivery mode와 policy revision; +- not-before, expiry, retry horizon; +- tenant, correlation, causation; +- recipient reference와 consent/preference evidence; +- submission outcome 또는 receipt. + +`void` 반환과 global fail-open을 조합하면 caller는 provider가 실행되지 않은 경우도 성공한 +호출과 구분할 수 없다. 이 shape는 non-critical telemetry-like best-effort compatibility +외에는 정확한 업무 계약이 될 수 없다. + +### 3.2 route list가 fan-out 의미로 고정된다 + +`RoutingNotifier`는 route의 provider ID list를 순서대로 모두 호출한다. + +```text +app.notification.routes..=provider-a,provider-b +``` + +이 list가 의미하는 바는 현재 무조건 `FAN_OUT_ALL`이다. 다음을 구분할 필드가 없다. + +- 정확히 하나만 호출하는 `SINGLE`; +- definite failure 때만 다음 provider로 넘어가는 `ORDERED_FALLBACK`; +- 모든 provider에 독립 delivery를 만드는 `FAN_OUT_ALL`. + +각 provider는 호출 전에 `FailOpenNotificationProvider`로 감싸져 outcome을 잃는다. 따라서 +router는 fallback 결정을 할 수도 없고 provider별 delivery 상태를 남길 수도 없다. + +### 3.3 설정의 activation source가 서로 어긋난다 + +현재 bootstrap/sample YAML은 다음 selector를 노출한다. + +```text +app.notification.slack.provider = APP_NOTIFICATION_SLACK_PROVIDER +app.notification.email.provider = APP_NOTIFICATION_EMAIL_PROVIDER +``` + +env registry와 optional contract test도 이 두 selector를 기준으로 한다. 그러나 provider +configuration은 다음 legacy boolean을 조건으로 사용한다. + +```text +app.notification.slack-webhook.enabled=true +app.notification.google-email.enabled=true +``` + +실제 route binding은 default YAML에 없다. 즉 문서/환경 SSOT가 말하는 active provider와 bean +activation이 같은 graph를 만들지 않는다. 이 상태에서 selector가 채워졌다는 사실은 provider가 +생성되거나 route가 usable하다는 증거가 아니다. + +### 3.4 provider는 실제 client가 아니다 + +`SlackClient`와 `GoogleEmailClient`는 extension interface이며 production 구현이 없다. +`SlackWebhookProvider`와 `GoogleEmailProvider`는 이 client를 호출하는 wrapper다. build +dependency에도 Slack SDK, AWS SDK, Gmail SDK 또는 SMTP client가 없다. + +따라서 현재 provider ID는 다음과 같이 해석해야 한다. + +| provider ID | 현재 의미 | production 보장 | +| --- | --- | --- | +| `slack-webhook` | injected fake/client seam | 없음 | +| `google-email` | injected fake/client seam | 없음 | + +### 3.5 durable workflow가 없다 + +현재 `NotificationPort` 호출과 함께 저장되는 intent가 없고 dispatcher/claim/attempt journal도 +없다. process crash, timeout, provider ACK loss, DB update 실패 뒤에 다음을 판별할 근거가 없다. + +- 전송을 시작하지 않았는가; +- provider가 거부했는가; +- provider는 받았지만 응답을 잃었는가; +- provider message ID를 받았으나 local commit 전에 죽었는가; +- 다시 보내면 중복이 되는가. + +기존 generic application outbox는 event publication을 위한 mutable row와 generic publisher +shape다. notification은 recipient별 fan-out, template snapshot, provider attempt, +indeterminate/reconciliation, feedback event, encrypted PII 보존이 필요하므로 그 row를 그대로 +재사용하지 않는다. + +### 3.6 production consumer가 없다 + +repository의 production source에서 `NotificationPort`를 호출하는 feature use case가 없다. +현재 테스트는 routing skeleton의 local behavior만 증명한다. 이 설계는 sample feature를 +억지로 consumer로 만들지 않고 먼저 reusable capability contract를 확정한다. + +### 3.7 문서도 현재 코드와 일부 어긋난다 + +notification README의 module guidance와 실제 leaf의 `CLAUDE.md`, selector 설명과 legacy enabled +condition, route activation 설명 사이에 drift가 있다. Phase 0에서 코드 변경 전 현재 truth를 +한 표로 정리하고 서로 다른 activation source를 동시에 유지하지 않는다. + +주요 근거 파일: + +- `src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/config/architecture/modules.json` + +### 3.8 independent design review + +2026-07-28에 architecture boundary, 기존 Redis/Fileserver/HTTP Client 설계 일관성, +durability/receipt 세 관점으로 독립 read-only review와 correction re-review를 수행했다. + +- 최초 review의 mode policy leak, sibling compiler, recipient/provider-leg identity, transaction + root, crypto/HMAC, exact card, SES/SNS topology, wire authorization/lease, receipt projection, + admission park 지적을 본문에 반영했다; +- correction re-review 결과 세 관점 모두 blocker 0, high 0이다; +- 이는 design consistency evidence이며 구현/real-provider R2 evidence가 아니다. + +baseline verification: + +- `./gradlew :adapter:outbound:notification:test --rerun-tasks --console=plain` + → 19 tests, failures/errors/skipped 0; +- `./gradlew verifyCleanArchitectureDependencies --console=plain` + → build success. + +## 4. 범위와 명시적 비범위 + +### 4.1 최소 R2 baseline에 포함 + +- `EMAIL`, `SLACK` channel; +- feature-specific application request factory/policy와 outbound notification port; +- versioned notification kind, route, template, locale와 typed parameter; +- `BEST_EFFORT_INLINE`, `DURABLE_ASYNC` mode; +- `SINGLE` provider R2와 `FAN_OUT_ALL`, `ORDERED_FALLBACK` R1 kernel; +- one-recipient logical intent, N provider leg, M physical attempt model; +- provider-neutral attempt outcome과 indeterminate state; +- source DB transaction과 함께 저장되는 durable intent; +- PostgreSQL/JPA 기반 claim, attempt journal, retry, expiry와 reconciliation; +- checked-in local template rendering; +- Slack Web API `chat.postMessage`; +- Amazon SES v2 `SendEmail`; +- Slack conversation post reference와 SES message ID 저장; +- SES bounce/complaint/delivery/delivery-delay feedback intake. rendering failure는 provider-stored + template optional card에서만 지원; +- technical suppression과 application-owned consent/preference 분리; +- canonical provider binding과 zero-resource disabled behavior; +- deadline, concurrency, queue, retry와 fan-out 상한; +- PII direct-AEAD encryption/redaction/retention; +- startup/readiness/metrics/traces/runbook; +- fake/local protocol test와 explicit real-provider qualification lane. + +### 4.2 R2 뒤에 열어둘 optional capability + +- Slack incoming webhook compatibility provider; +- Gmail API provider; +- SMTP provider; +- provider-stored SES template; +- SMS, push, mobile/web inbox; +- cross-region active/active dispatcher; +- broker wake-up/partitioning; +- user-facing notification preference center; +- provider message update/delete; +- Slack event-based conversation reconciliation; +- marketing analytics/open/click tracking; +- multi-recipient batch provider API. + +optional provider는 동일한 capability name 아래 자동 호환으로 간주하지 않는다. 각 provider +card가 정확한 submission, idempotency, receipt, sandbox, quota, reconciliation 보장을 선언하고 +요구 profile을 통과해야 한다. + +### 4.3 이번 범위에서 제외 + +- notification 내용을 결정하는 domain business rule; +- controller/filter/settings/mapper의 notification policy; +- arbitrary raw email/Slack body 전송 SDK; +- caller-supplied Slack webhook URL/channel ID 또는 email provider credential; +- provider SDK type를 application에 반환하는 API; +- external send와 DB commit의 distributed transaction; +- exactly-once delivery 또는 exactly-once user visibility; +- email inbox 도착, 열람 또는 Slack 사용자 읽음 보장; +- provider별 마케팅 캠페인 orchestration; +- production leaf에서 sample WorkLog 개념 사용; +- notification leaf가 persistence/messaging/httpclient sibling leaf를 직접 의존하는 구조; +- 일반-purpose cron/job framework; +- business unsubscribe를 bounce suppression table로 대체하는 구조. + +## 5. HARD invariants + +다음 조건은 구현 편의를 위해 낮출 수 없다. + +1. `domain-core`에 Spring, JPA, Slack/AWS/Gmail/SMTP, JSON, HTTP 타입을 넣지 않는다. +2. controller는 notification provider, repository, persistence entity를 직접 사용하지 않는다. +3. inbound DTO를 application notification command나 template parameter로 재사용하지 않는다. +4. notification adapter가 consent, preference, quiet-hours 또는 “누구에게 알려야 하는가”를 + 결정하지 않는다. +5. business use case가 provider ID, webhook URL, channel ID, AWS region, credential을 선택하지 + 않는다. +6. provider SDK request/response/exception을 application contract에 노출하지 않는다. +7. critical/durable route를 caller flag로 best-effort에 downgrade하지 않는다. +8. `void` + exception swallow를 durable 또는 critical success로 표현하지 않는다. +9. provider accepted와 recipient delivered/read를 같은 상태로 표현하지 않는다. +10. timeout, connection loss, ACK loss를 definite-not-sent로 간주하지 않는다. +11. `INDETERMINATE` attempt를 근거 없이 blind retry하지 않는다. +12. fallback은 `submissionCertainty=DEFINITELY_NOT_APPLIED`에서만 진행한다. unknown duplicate + tolerance/escalation은 별도 application intent로 표현한다. +13. 외부 send와 local DB update 사이 exactly-once를 주장하지 않는다. +14. hidden SDK retry가 physical-attempt budget 밖에서 실행되지 않게 한다. +15. route/provider/template revision을 진행 중 intent에 조용히 재해석하지 않는다. +16. raw recipient, body, subject, template parameter, token, provider message ID를 metric tag로 + 사용하지 않는다. +17. secret/webhook URL/access token을 log, exception message, receipt 또는 callback payload + snapshot에 남기지 않는다. +18. durable queue의 recipient/content를 plaintext로 무기한 저장하지 않는다. +19. provider feedback의 technical suppression을 business consent의 정본으로 사용하지 않는다. +20. route binding이 없으면 client, scheduler, thread, connection, probe, callback subscription을 + 만들지 않는다. +21. notification adapter가 persistence, messaging, inbound 또는 HTTP client adapter에 + project dependency를 추가하지 않는다. +22. real-provider evidence 없이 provider R2를 주장하지 않는다. +23. Slack `ts`를 user delivery/read receipt로 표현하지 않는다. +24. SES `MessageId`와 `DELIVERY` event를 inbox/read receipt로 표현하지 않는다. +25. callback은 signature/authenticity, expected account/topic/workspace와 replay/deduplication을 + 검증하기 전 application command로 승격하지 않는다. +26. retry, target, fallback과 provider call의 총 amplification 상한이 없는 route를 활성화하지 + 않는다. +27. provider quota, backpressure 또는 DB backlog가 무한 queue/worker 생성으로 이어지지 않는다. +28. stale claim owner가 새 owner의 attempt를 finalize하지 못하게 한다. +29. 같은 business operation의 반복 요청이 새 intent인지 retry인지 stable identity 없이 + 추론되지 않게 한다. +30. notification capability가 business transaction commit 전에 외부 provider를 먼저 호출하지 + 않는다. + +## 6. 대안 검토 + +### 6.1 현재 `void NotificationPort` 확장 + +장점: + +- 변경량이 가장 작다; +- 현재 router/fan-out code를 유지할 수 있다. + +탈락 이유: + +- outcome, durable append, attempt, receipt를 표현할 수 없다; +- raw recipient/body가 template와 policy boundary를 우회한다; +- global fail-open을 feature별 failure policy로 바꿀 수 없다. + +판정: R0 legacy compatibility에만 유지한다. + +### 6.2 provider가 DB/outbox에 직접 기록 + +장점: + +- notification leaf 안에서 durable workflow가 한곳에 보인다. + +탈락 이유: + +- registry가 notification -> persistence project edge를 허용하지 않는다; +- application transaction boundary가 adapter에 역전된다; +- provider implementation이 business workflow와 storage orchestration을 소유하게 된다. + +판정: 채택하지 않는다. + +### 6.3 message broker를 durability의 정본으로 사용 + +장점: + +- worker scale-out과 wake-up이 쉽다; +- retry/topic partition tooling을 활용할 수 있다. + +탈락 이유: + +- business DB commit과 broker publish 사이 dual-write가 생긴다; +- provider fan-out/attempt/receipt/PII retention을 broker event 하나로 해결하지 못한다; +- notification leaf -> messaging edge도 registry에 없다. + +판정: R2 baseline은 same-source DB intent가 정본이다. 이후 broker에는 opaque intent ID만 +outbox로 발행해 wake-up hint로 사용할 수 있다. DB claim/state가 계속 정본이다. + +### 6.4 기존 generic application outbox row를 그대로 재사용 + +장점: + +- 테이블과 scheduler 수가 적다; +- 기존 polling/publish 흐름을 재사용할 수 있다. + +탈락 이유: + +- 현재 outbox에는 recipient별 delivery/attempt/receipt identity가 없다; +- provider response loss와 reconciliation 상태가 없다; +- encrypted payload, consent snapshot, route/template revision 보존 계약이 없다; +- generic publisher의 성공/실패와 notification recipient outcome이 다르다. + +판정: scheduler/claim pattern은 참고할 수 있지만 notification 전용 aggregate/table과 port를 +만든다. 공통화는 두 capability의 invariant가 검증된 뒤 별도 설계로 진행한다. + +### 6.5 provider 자체 retry와 idempotency에 전적으로 의존 + +장점: + +- application coordinator가 단순하다. + +탈락 이유: + +- Slack과 SES의 baseline send API에는 운영상 의존할 문서화된 native idempotency key가 없다; +- SDK hidden retry는 physical calls와 duplicate risk를 가린다; +- provider accepted 뒤 local commit 실패를 해결하지 못한다. + +판정: coordinator가 logical retry와 budget을 소유한다. SDK retry는 disable하거나 모든 wire +attempt가 같은 journal/budget에 계수된다는 evidence가 있을 때만 허용한다. + +### 6.6 Slack Incoming Webhook을 baseline으로 사용 + +장점: + +- payload와 인증이 단순하다; +- 작은 고정 채널 알림에 적합하다. + +탈락 이유: + +- URL이 destination과 secret을 함께 결합한다; +- 요청에서 목적지를 바꿀 수 없다; +- 성공 응답에 message `ts`가 없어 reconciliation이 약하다; +- update/delete와 dynamic route 요구가 제한된다. + +판정: `chat.postMessage`를 R2 reference로, incoming webhook은 고정 목적지 legacy compatibility +provider로 둔다. + +### 6.7 Gmail API를 generic email baseline으로 사용 + +장점: + +- Gmail/Workspace mailbox identity에 자연스럽다; +- Gmail message resource와 mailbox 기능을 활용할 수 있다. + +탈락 이유: + +- per-user quota와 OAuth/domain-wide delegation 운영 복잡도가 baseline에 결합된다; +- generic transactional email feedback/bounce lifecycle의 정본이 아니다; +- 현재 `google-email` 이름이 Gmail API인지 SMTP인지도 명확하지 않다. + +판정: Amazon SES v2를 초기 R2 reference로 선택한다. Gmail API는 별도 exact provider card가 +필요한 optional provider다. + +## 7. capability와 evidence 용어 + +### 7.1 readiness level + +| Level | 의미 | 허용 표현 | +| --- | --- | --- | +| R0 | interface, fake, skeleton 또는 legacy seam | “extension seam”, “설계/골격” | +| R1 | local deterministic behavior와 contract test | “local behavior verified” | +| R2 | 선택된 real provider profile의 운영 필수 보장과 failure evidence | “provider/profile R2” | +| R3 | production-like scale/failover/rotation/운영훈련 evidence | “해당 topology/profile R3” | + +readiness는 module 전체의 단일 숫자가 아니다. + +```text +NotificationCapabilityCard = + card ID/revision + + provider binding ID/revision + + channel + + application policy mode + + route strategy + + template/render/serialization revision + + submission semantics + + pre-send correlation/native idempotency + + reconciliation lookup mode + + receipt transport/projections + + credential source/account/region/workspace profile + + tested evidence revision/maturity +``` + +예: + +```text +aws-ses-v2-durable-single-local-sns-v1 +/ aws-ses-primary@v3 / EMAIL / DURABLE_ASYNC / SINGLE +/ LOCAL_RENDERED@v4 + canonical-email-v2 +/ API_ACCEPTED_MESSAGE_ID / ATTEMPT_TAG_NO_NATIVE_IDEMPOTENCY +/ PRE_SEND_CORRELATION_EVENT_LOOKUP / SNS_HTTPS@v1 +/ account+ap-northeast-2+WEB_IDENTITY / evidence-2026-07-28 / R1 +``` + +다른 provider, region, template mode, callback topology 또는 credential mode로 일반화하지 +않는다. + +### 7.2 normative state wording + +| 표현 | 정확한 의미 | +| --- | --- | +| `APPENDED` | local durable intent transaction이 commit됨 | +| `WIRE_AUTHORIZED` | eligibility 재검사를 통과하고 physical provider call 권한을 durable commit함 | +| `PROVIDER_ACCEPTED` | provider API가 요청 수락을 응답함 | +| `POSTED_TO_CONVERSATION` | Slack conversation에 message reference가 생성됨 | +| `DELIVERED_TO_RECIPIENT_MTA` | email recipient 측 MTA가 수락했다는 provider feedback | +| `BOUNCED` | provider가 bounce feedback을 보고함 | +| `COMPLAINED` | provider가 complaint feedback을 보고함 | +| `TERMINAL_INDETERMINATE` | reconcile horizon 뒤에도 provider side effect 여부를 확정할 수 없음 | + +`DELIVERED`, `SUCCESS`, `SENT` 같은 단독 표현은 provider/card 문맥 없이 terminal 상태 이름으로 +사용하지 않는다. + +## 8. 모듈 소유권과 dependency direction + +현재 `src/config/architecture/modules.json`에 따르면 notification leaf의 허용 production +dependency는 다음뿐이다. + +```text +adapter-outbound-notification + -> domain-core + -> application-core + -> shared-contract + -> adapter-outbound-support +``` + +이 registry를 유지한 상태에서 소유권을 다음처럼 나눈다. + +| 책임 | 소유 모듈 | 금지 | +| --- | --- | --- | +| notification eligibility와 business invariant | `domain-core` 또는 feature application | adapter/config에서 결정 | +| feature-specific request factory/policy와 use case | `application-core` | transport/provider DTO | +| framework-free intent/plan/store/dispatch 계약 | `application-core` | Spring/JPA/SDK 타입 | +| route/template catalog, renderer | `adapter-outbound-notification` | business consent | +| provider request/response mapping | `adapter-outbound-notification` | persistence entity | +| durable notification table/repository adapter | `adapter-outbound-persistence-jpa` | provider SDK | +| raw callback auth/transport mapping | 현재 `adapter-inbound-web` | provider send implementation | +| scheduler, worker bean, provider/store composition | `app-bootstrap` | business policy | +| optional broker wake-up | messaging/outbox 관련 owner leaf | DB state 대체 | +| sample WorkLog notification consumer | `sample-portfolio` | production leaf로 역의존 | + +### 8.1 application orchestration + +application service는 다음 port를 조합할 수 있다. + +```text +Feature use case + -> FeatureNotificationRequestFactory + NotificationKindPolicy + -> InlineNotificationAttemptPort + or NotificationIntentAppendPort + +NotificationDispatchUseCase + -> NotificationDeliveryStorePort + -> NotificationProviderAttemptPort + -> NotificationReceipt/ReconciliationPort +``` + +application policy/factory에는 `Port` 이름을 붙이지 않는다. interface의 최종 분할은 구현 +계획에서 package cohesion을 검증하되, 하나의 giant +`NotificationPort`로 plan/store/send/receipt를 다시 합치지 않는다. + +### 8.2 callback ownership + +SES/SNS, Slack event 또는 provider webhook의 raw HTTP signature 검증은 inbound adapter +책임이다. 현재 registry는 inbound web -> notification adapter edge를 허용하지 않으므로 inbound +adapter는 provider SDK event object를 넘기지 않는다. + +```text +HTTP callback + -> inbound signature/account/topic/workspace verification + -> framework-free NormalizedNotificationReceiptCommand + -> application receipt use case + -> persistence receipt/delivery state port +``` + +provider callback 종류가 커지고 inbound lifecycle이 독립 배포/의존성을 요구하면 +`adapter:inbound:notification` leaf 추가를 registry migration으로 별도 제안한다. outbound leaf에 +controller/listener를 넣지 않는다. + +## 9. application-facing 계약 + +### 9.1 feature-specific port가 우선이다 + +business code가 generic channel/body를 직접 조립하지 않도록 feature별 application policy/factory를 +둔다. + +개념 예: + +```java +public final class PasswordResetNotificationRequestFactory { + NotificationIntentDraft create(PasswordResetNotice notice); +} +``` + +`PasswordResetNotice`에는 business 의미와 이미 검증된 opaque recipient reference만 있고 Slack, +SES, HTML, Block Kit, webhook URL은 없다. application-owned factory와 +`NotificationKindPolicy`가 notification kind/route/template/mode/admission class를 closed +catalog에서 고른다. + +`PasswordResetNotificationRequestFactory`는 outbound port가 아니며 이름에 `Port`를 붙이지 +않는다. 실제 외부 side effect와 저장은 application-core가 선언한 +`InlineNotificationAttemptPort`, `NotificationIntentAppendPort`, +`NotificationDeliveryStorePort` 같은 outbound port 뒤에 둔다. generic foundation은 feature +application policy를 구현하기 위한 내부 capability이며, 모든 use case가 raw template ID와 +parameter map을 자유롭게 호출하는 public utility로 제공하지 않는다. + +### 9.2 intent command의 최소 의미 + +framework-free command는 개념적으로 다음 값을 갖는다. + +```text +NotificationIntentDraft + intentId + notificationKind + channel + routeId + templateRef(id, version) + locale + recipientRef + typedParameters + mode + idempotencyScope + sourceOperationId + tenant/correlation/causation + notBefore + expiresAt + policyRevision + admissionClass +``` + +정확한 Java record 분할은 다음 원칙을 따른다. + +- 모든 ID는 bounded value object다; +- `Map`와 raw JSON string은 사용하지 않는다; +- inbound request DTO를 생성자 인자로 받지 않는다; +- recipient는 email address/Slack channel을 한 raw string union으로 만들지 않는다; +- address/channel lookup이 필요한 경우 opaque `RecipientReference`와 application resolver port를 + 사용한다; +- `Clock`/time policy는 testable application dependency이며 adapter가 expiry를 임의 결정하지 + 않는다; +- mode와 admission class는 application의 `NotificationKindPolicy`가 고정한다. +- 최소 R2에서 intent 하나는 logical recipient를 정확히 한 명만 갖는다. bulk는 별도 + `RecipientDelivery` dimension을 설계하기 전까지 허용하지 않는다. + +### 9.3 request result + +request 결과는 delivery 성공을 뜻하지 않는다. + +```text +NotificationRequestResult = + InlineCompleted(bounded TargetAttemptOutcome list) + | AppendedDurably(intentReference) + | DuplicateExistingIntent(intentReference) + | RejectedByBusinessPolicy(reasonCode) + | RejectedInvalidRequest(reasonCode) + | CapabilityUnavailable(reasonCode) +``` + +`InlineCompleted`는 `SINGLE`뿐 아니라 bounded `FAN_OUT_ALL`의 부분 성공/실패를 target ordinal별로 +표현한다. `TargetAttemptOutcome`은 §15.3의 직교 outcome을 사용한다. + +각 결과는 bounded reason code와 opaque intent/reference를 가질 수 있다. provider ID, raw +address, SDK error 또는 persistence entity를 반환하지 않는다. + +### 9.4 business policy snapshot과 recheck + +enqueue 전 application은 적어도 다음을 판단한다. + +- notification이 business적으로 필요한가; +- recipient가 누구인가; +- legal/consent/preference가 허용하는가; +- quiet hours/not-before가 적용되는가; +- expiry 이후 가치가 남는가; +- 같은 source operation에서 이미 요청했는가. + +시간이 긴 durable marketing notification은 dispatch 직전 consent/preference 재확인이 필요할 +수 있다. 이 여부와 recheck port는 notification kind policy가 고정한다. + +```text +ConsentCheckMode = + SNAPSHOT_AT_APPEND + RECHECK_BEFORE_EACH_DELIVERY +``` + +security/password-reset처럼 법적 근거와 urgency가 다른 종류를 marketing default로 묶지 않는다. + +## 10. identity, fingerprint와 revision + +### 10.1 identity 계층 + +| ID | 범위 | 용도 | +| --- | --- | --- | +| `NotificationIntentId` | logical business notification | append dedupe, 조회, correlation | +| `NotificationDeliveryId` | 한 provider leg/technical target | fan-out/fallback 상태 | +| `NotificationAttemptId` | 한 physical provider call | journal, budget, latency | +| `NotificationReceiptEventId` | normalized provider feedback | callback dedupe | +| `NotificationKindId` | business 의미 | policy/catalog lookup | +| `NotificationRouteId` | logical technical route | binding lookup | +| `NotificationTemplateId` + version | immutable content contract | rendering/replay | +| provider message reference | provider-local opaque reference | reconcile/feedback | + +provider message reference는 `(provider, account/workspace, providerMessageId)`처럼 provider +namespace와 함께 저장하며 application aggregate ID로 사용하지 않는다. + +최소 R2에서 `NotificationDeliveryId`는 recipient가 아니라 한 provider leg의 identity다. +intent의 logical recipient는 정확히 한 명이고, `SINGLE/FAN_OUT_ALL/ORDERED_FALLBACK`이 여러 +provider leg를 만들 수 있다. 향후 bulk notification은 +`Intent -> RecipientDelivery -> ProviderLeg -> Attempt` 계층을 별도 card로 도입해야 한다. + +### 10.2 source operation과 idempotency + +caller가 자유로운 idempotency string을 만드는 대신 feature가 stable source operation ID와 +closed scope를 제공한다. + +```text +fingerprint = HMAC-SHA-256( + purpose = intent-fingerprint, + hmacKeyVersion, + length-prefixed( + tenant, + notificationKind, + sourceOperationId, + recipientCanonicalDigest, + semanticParameterDigest, + policyRevision + ) +) +``` + +원칙: + +- delimiter concatenation을 사용하지 않는다; +- raw address/content를 fingerprint column에 넣지 않는다; +- secret이 아닌 plain SHA만으로 low-entropy email을 역추측할 수 있게 하지 않는다; +- retry마다 새 random intent ID만 생성해 dedupe를 우회하지 않는다; +- fingerprint version과 HMAC key version을 저장한다; +- 같은 source operation에서 의도적으로 여러 알림이 필요하면 bounded occurrence ID를 + semantic input으로 명시한다. + +HMAC은 purpose별 key와 version을 사용한다. lookup은 `current + bounded retiring keys`의 +digest를 계산한다. rolling rotation에서 old writer가 남아 있는 동안 새 owner write는 current와 +모든 retiring digest alias를 같은 transaction에 insert한다. 따라서 old/new writer가 경쟁해도 +공통 retiring alias unique constraint가 한 owner만 허용한다. old writer drain 뒤에는 current +alias만 쓰고, match된 retiring digest는 같은 transaction에서 current-key alias로 승격한다. + +alias table은 `(scope, purpose, key_version, digest)`와 +`(owner_type, owner_id, purpose, key_version)`를 각각 unique로 두며 하나의 alias가 서로 다른 +semantic owner를 가리키면 startup/runtime conflict로 막는다. + +old HMAC key는 suppression, source dedupe, provider-event dedupe, orphan receipt, message-reference +lookup과 tombstone이 모두 만료되었거나 current-key alias/re-HMAC migration을 마친 뒤에만 +retire한다. 무기한 suppression은 recipient ciphertext를 지우기 전에 current key로 re-HMAC해야 +한다. email canonicalization은 local part를 보존하고 domain의 case/IDNA normalization만 exact +version으로 정의한다. Gmail식 dot 제거 또는 plus suffix 제거를 generic email에 적용하지 않는다. + +### 10.3 frozen plan revision + +append된 intent에는 다음 immutable snapshot/digest를 보존한다. + +- notification kind policy revision; +- route plan revision; +- template ID/version/checksum; +- renderer/canonical serialization/escaping revision; +- locale/fallback decision; +- target count와 target opaque reference; +- delivery mode, strategy, attempt/fallback limit; +- consent check mode와 expiry; +- rendering parameter schema version. + +provider credential 값이나 full physical endpoint는 snapshot에 저장하지 않는다. 그러나 진행 +중 intent가 새 config로 자동 재해석되지 않도록 모든 live/retained intent가 참조하는 plan, +provider binding, template, renderer, canonical serialization과 escaping revision을 유지한다. + +단순 N/N-1 규칙으로 N-2 backlog를 제거하지 않는다. 삭제하려는 revision에 live/retained intent가 +있으면 startup 또는 rollout guard가 차단한다. + +## 11. delivery mode + +### 11.1 `BEST_EFFORT_INLINE` + +정확한 계약: + +- business transaction commit 뒤 또는 transaction이 없는 명시적 boundary에서 호출한다; +- process crash recovery가 없다; +- durable retry/receipt를 보장하지 않는다; +- provider attempt가 실패해도 feature 정책에 따라 business 결과를 유지할 수 있다; +- provider attempt outcome은 관측 가능하게 반환한다; +- route가 없으면 fail-fast하며 silent no-op하지 않는다; +- critical/durable kind에는 binding할 수 없다. + +현재 `FailOpenNotificationProvider`처럼 exception을 삼킨 뒤 `void`로 끝내지 않는다. +best-effort 결과는 `InlineCompleted(bounded TargetAttemptOutcome list)`이며 각 target outcome은 +§15.3의 submission certainty, retry disposition, fault scope를 그대로 보존한다. + +best-effort caller가 실패를 business 응답에 반영하지 않더라도 metric/log/audit outcome은 잃지 +않는다. 이 mode는 “실패를 무시한다”가 아니라 “delivery를 durable하게 추적하지 않는다는 +명시적 선택”이다. + +### 11.2 `DURABLE_ASYNC` + +정확한 계약: + +- source business write와 같은 transaction에서 intent append가 성공해야 한다; +- append 실패 시 critical feature policy에 따라 business transaction도 실패한다; +- append method가 자체 `REQUIRES_NEW` transaction으로 원자성을 깨지 않는다; +- commit 뒤 dispatcher가 claim한다; +- external send는 DB transaction 밖에서 수행한다; +- provider별 attempt와 outcome을 durable하게 기록한다; +- retry/expiry/reconciliation/receipt/retention 정책을 가진다; +- backlog와 terminal outcome을 query/operate할 수 있다. + +`APPENDED_DURABLY`는 provider accepted 또는 recipient outcome을 뜻하지 않는다. + +### 11.3 mode 선택 권한 + +mode는 application code의 `NotificationKindPolicy`만 정한다. + +```text +PASSWORD_RESET_EMAIL -> DURABLE_ASYNC +SECURITY_ALERT_SLACK -> DURABLE_ASYNC +LOW_VALUE_DEV_HINT_SLACK -> BEST_EFFORT_INLINE +``` + +inbound request의 `durable=false`, query parameter, route catalog, settings 또는 arbitrary +application boolean으로 mode를 선택하거나 바꾸지 않는다. config의 `expected-mode`는 application +policy와 일치하는지 검증하는 assertion일 뿐이며 mismatch는 startup failure다. config가 +best-effort를 durable로 “강화”하는 것도 transaction sequencing과 business failure 의미를 +바꾸므로 금지한다. mode 변경은 application policy revision과 해당 feature use case의 transaction +sequence를 함께 변경하고 검증해야 한다. + +### 11.4 transaction sequence와 use case capability + +현재 `TransactionPort.inWrite`는 `PROPAGATION_REQUIRED`이므로 이미 열린 outer transaction에 +참여할 수 있다. 따라서 단순히 `inWrite`가 반환했다는 사실을 physical commit 완료로 간주하면 +안 된다. synchronous `InlineCompleted`를 유지하는 최소 R2는 기존 application +`TransactionPort`에 `inRootWrite` 계약을 추가한다. + +이 port의 persistence implementation은 시작 전에 ambient physical transaction이 없음을 +검사하고, 있으면 business write나 provider call 전에 typed +`NestedRootTransactionRejectedException`으로 fail-fast한다. ambient transaction이 없을 때만 root REQUIRED +transaction을 열고 physical commit이 끝난 뒤 반환한다. + +```java +CommittedBusinessResult committed = + tx.inRootWrite( + () -> { + BusinessResult saved = repository.save(command); + return CommittedBusinessResult.of(saved, factory.create(saved)); + }); + +// physical root commit이 성공한 뒤에만 실행한다. +InlineCompleted inline = + inlineAttemptUseCase.handle(committed.inlineDraft()); +``` + +durable kind는 기존 `TransactionPort.inWrite` 안에서 business write와 +`NotificationIntentAppendPort.append`를 함께 실행해 caller의 ambient transaction에도 +의도적으로 참여한다. append adapter는 `REQUIRES_NEW`를 사용하지 않는다. + +root transaction rollback/commit failure 또는 ambient transaction rejection이면 best-effort +provider call은 0이어야 한다. `outer inWrite -> best-effort feature use case -> outer rollback` +통합 테스트는 provider call 0과 root-boundary rejection을 증명한다. after-commit registration +방식을 향후 도입하면 synchronous `InlineCompleted`를 그대로 재사용하지 않고 별도 +`ScheduledAfterCommit` 계약/card로 설계한다. + +`inRootWrite`의 `TransactionMode`는 여전히 `WRITE`, propagation은 `REQUIRED`, +isolation은 `READ_COMMITTED`다. `NEVER` mode/propagation을 추가하지 않고 adapter가 transaction +시작 전 actual ambient transaction precondition을 검사한다. 구현 시 application-core와 +persistence-jpa의 `CLAUDE.md`/README, fake port와 transaction fitness/integration test를 함께 +갱신해야 한다. + +dispatcher는 `CommandUseCase`를 구현하고 다음 fitness contract를 선언한다. + +```java +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +``` + +claim과 finalize는 각각 짧은 `TransactionPort.inWrite` 안에서 실행하고 provider call은 그 +사이에 transaction 밖에서 실행한다. application에는 `*Port`를 구현하는 policy class를 만들지 +않고, inbound use case/application policy/outbound port를 타입과 이름으로 구분한다. + +### 11.5 admission class + +application `NotificationKindPolicy`는 closed `NotificationAdmissionClass`도 선택한다. + +```text +SECURITY_CRITICAL +TRANSACTIONAL +BULK_LOW_VALUE +``` + +이 값은 intent에 저장되고 claim ordering/admission partition의 입력이 된다. config는 각 class의 +concurrency를 낮출 수만 있으며 kind의 class를 바꾸지 못한다. ordering은 +`admission class + bounded aging + next_action_at + notification_id`로 deterministic하게 +정의하고 낮은 class도 maximum starvation window 안에 기회를 얻어야 한다. + +## 12. durable state model + +### 12.1 state를 한 column에 합치지 않는다 + +최소 R2에서 intent 하나는 logical recipient 한 명을 갖고 여러 provider leg와 늦은 feedback을 +가질 수 있으므로 다음을 분리한다. + +```text +NotificationIntent + exactly 1 logical recipient + 1 -> N NotificationDeliveryLeg + 1 -> M immutable NotificationAttempt + 0 -> K immutable NotificationReceiptEvent +``` + +`NotificationDelivery`라는 기존 개념명은 provider leg를 뜻한다. 구현에서는 혼동을 피하기 위해 +`NotificationDeliveryLeg`를 우선 사용한다. intent summary는 leg state와 receipt fact에서 +파생하는 view/projection이다. provider별 부분 성공, fallback 대기 또는 bounce를 하나의 +`SENT` boolean으로 덮지 않는다. + +### 12.2 intent state + +권장 intent control state: + +```text +APPENDED +ACTIVE +COMPLETED +PARTIALLY_COMPLETED +TERMINAL_FAILED +CANCELLED +EXPIRED +``` + +`COMPLETED`는 이름만으로 최종 recipient 성공을 뜻하지 않으며, kind policy가 요구한 submission +objective를 만족했다는 뜻이다. +예를 들어 Slack은 `POSTED_TO_CONVERSATION`, transactional email은 +`PROVIDER_ACCEPTED` 또는 receipt policy에 따른 `DELIVERED_TO_RECIPIENT_MTA`가 될 수 있다. +서로 다른 기준을 같은 dashboard에서 비교할 때 capability card를 함께 표시한다. +후속 bounce/complaint는 immutable adverse fact/projection으로 함께 노출하며 accepted submission +fact를 지우지 않는다. + +### 12.3 delivery control state + +권장 provider leg state: + +```text +BLOCKED +QUEUED +CLAIMED +ATTEMPT_RESERVED +WIRE_AUTHORIZED +PROVIDER_ACCEPTED +RETRY_WAIT +PARKED_BINDING +RECONCILE_WAIT +RECONCILING +PERMANENTLY_REJECTED +SUPPRESSED +POLICY_REJECTED +TERMINAL_INDETERMINATE +CANCELLED +EXPIRED +``` + +`ORDERED_FALLBACK`은 첫 leg만 `QUEUED`, 나머지는 `BLOCKED`로 생성한다. `INDETERMINATE`라는 +모호한 transient/terminal 단일 state는 쓰지 않는다. reconcile 가능한 unknown은 +`RECONCILE_WAIT/RECONCILING`, horizon이 끝난 unknown은 `TERMINAL_INDETERMINATE`다. + +receipt는 단일 mutually-exclusive state가 아니라 immutable fact와 직교 projection으로 둔다. + +```text +SubmissionProjection = + UNKNOWN | ACCEPTED | DEFINITELY_REJECTED + +RecipientTransportProjection = + UNKNOWN | DELAYED | MTA_ACCEPTED | BOUNCED | FAILED_AFTER_ACCEPT + +AbuseProjection = + NONE | COMPLAINED + +ConversationPresenceProjection = + NOT_APPLICABLE | POSTED | UNKNOWN +``` + +provider/card에 없는 projection을 기대하지 않는다. reducer는 verified fact의 도착 순서와 +provider timestamp 순서가 달라도 같은 fact set이면 같은 projection을 만드는 order-independent +함수다. complaint는 MTA accepted/bounced와 공존할 수 있고 submission accepted fact를 지우지 +않는다. + +### 12.4 핵심 transition + +```text +BLOCKED + -> QUEUED [바로 앞 fallback leg가 definite-not-applied로 terminal 되는 같은 transaction] + +QUEUED / RETRY_WAIT + -> CLAIMED(ownerToken, leaseUntil) + -> ATTEMPT_RESERVED(attemptExecutionToken) + -> WIRE_AUTHORIZED + -> PROVIDER_ACCEPTED + | RETRY_WAIT + | PARKED_BINDING + | PERMANENTLY_REJECTED + | RECONCILE_WAIT + | TERMINAL_INDETERMINATE + +RECONCILE_WAIT + -> RECONCILING + -> PROVIDER_ACCEPTED + | DEFINITELY_NOT_APPLIED -> RETRY_WAIT | PERMANENTLY_REJECTED + | STILL_UNKNOWN -> RECONCILE_WAIT | TERMINAL_INDETERMINATE + +PROVIDER_ACCEPTED + + immutable receipt facts + -> orthogonal projections + +PARKED_BINDING + -> QUEUED | EXPIRED | POLICY_REJECTED [audited resume transaction] +``` + +`WIRE_AUTHORIZED` commit이 provider I/O의 local linearization point다. SDK/client 호출은 이 +commit의 성공을 확인한 뒤에만 시작한다. 이 transaction은 expiry, cancellation request, +application policy recheck 결과, technical suppression, 모든 route/provider/account admission +gate의 expected generation이 `ACTIVE`인지와 attempt budget을 다시 검사한다. cancellation, +gate park와 wire authorization 중 먼저 commit된 transition이 이긴다. + +외부 consent/preferences store와 이 DB를 원자화할 수 없으므로 recheck 직후 revoke와 실제 send +사이의 race는 제거할 수 없다. 이 한계를 receipt나 exactly-once 표현으로 감추지 않는다. + +### 12.5 crash classification + +| crash/실패 위치 | 복구 판정 | +| --- | --- | +| claim 뒤 attempt reserve 전 | wire authorization이 없으므로 lease 만료 후 안전하게 re-claim | +| `ATTEMPT_RESERVED` 뒤 | wire authorization이 없으므로 안전하게 requeue | +| `WIRE_AUTHORIZED` commit 뒤 provider call 전 process kill | maybe-send window로 취급 | +| `WIRE_AUTHORIZED` 뒤 response 전 | reconcile 가능하면 `RECONCILE_WAIT`, 아니면 terminal unknown | +| provider accepted response 뒤 local projection update 전 | immutable attempt result를 terminal-once 기록한 뒤 projection 재적용 | +| local accepted commit 뒤 | accepted 유지, duplicate callback idempotent 적용 | +| receipt 저장 뒤 delivery projection update 전 | 같은 transaction이면 함께 rollback; 아니면 inbox 재적용 | + +delivery claim owner token과 immutable attempt execution token을 분리한다. lease를 잃은 worker도 +정확히 받은 provider response를 `(delivery_id, attempt_ordinal)`의 open attempt에 terminal-once로 +append할 수 있다. delivery projection은 현재 owner/version CAS로 merge하고, 이미 더 강한 +accepted/receipt fact를 stale result로 낮추지 않는다. + +`WIRE_AUTHORIZED` attempt는 `attempt_deadline + transport drain/finalize grace`가 지나기 전에는 +reaper가 retry나 fallback을 활성화하지 않는다. 그 뒤에도 provider card가 definite-not-applied를 +증명하지 않으면 reconcile/terminal unknown으로만 이동한다. DB linearization point와 실제 socket +write를 원자화할 수 없으므로 exactly-once를 주장하지 않는다. + +`TERMINAL_INDETERMINATE`는 자동 send/reconcile budget의 종료이지 과거 fact를 지우는 봉인이 +아니다. 나중에 도착한 verified receipt 또는 정확한 late provider response는 accepted projection으로 +단조롭게 해소할 수 있지만, 이를 이유로 새 physical send를 자동 시작하지 않는다. + +### 12.6 fallback atomicity + +predecessor leg의 attempt 결과가 +`submissionCertainty=DEFINITELY_NOT_APPLIED`로 terminal 되는 transaction에서만 정확히 다음 +`BLOCKED` leg 하나를 `QUEUED`로 바꾼다. `(notification_id, strategy_group)`당 active fallback +leg 최대 1개를 partial unique constraint 또는 동등한 invariant로 강제한다. accepted, +bounce, complaint와 terminal indeterminate는 자동 fallback 사유가 아니다. cross-channel +escalation은 별도 application intent다. + +`PARK_BINDING`은 initial kernel에서 fallback을 활성화하지 않는다. parked primary는 active +fallback leg로 남아 secondary를 막는다. future exact fallback card가 park 시 chain advance를 +원하면 predecessor를 definite-not-applied terminal로 닫고 다음 leg 하나를 같은 transaction에서 +활성화하는 별도 reviewed policy/state를 추가해야 한다. + +## 13. planning, routing, fan-out과 fallback + +### 13.1 application intent와 adapter plan 분리 + +application은 logical `NotificationRouteId`를 선택한다. adapter의 immutable catalog/compiler가 +notification-local route/template/provider capability만 검증해 frozen plan을 만든다. + +```text +NotificationDeliveryPlan + routeId + revision + channel + strategy + templateRef/checksum + target descriptors + required capability + attempt/fallback/total-call limits + per-attempt deadline + retry horizon + receipt expectation +``` + +plan은 provider-neutral application value로 돌아오되 target은 bounded opaque reference다. +provider SDK request나 secret/endpoint를 plan에 넣지 않는다. + +cross-module readiness를 이 compiler 하나에 넣지 않는다. + +| 검증 소유자 | 제공하는 framework-free descriptor/책임 | +| --- | --- | +| `adapter-outbound-notification` | route/template/provider local compile 결과와 `NotificationProviderCapabilityDescriptor` | +| `adapter-outbound-persistence-jpa` | schema/store/key support, live/retained revision을 담은 `NotificationStoreCapabilityDescriptor` | +| `adapter-inbound-web` | callback transport/auth/topology의 `NotificationReceiptIngressDescriptor` | +| `application-core` | kind policy와 세 descriptor를 비교하는 pure `NotificationCapabilityCompatibilityValidator` | +| `app-bootstrap` | canonical settings를 provider-neutral send/receipt runtime profile로 분할하고 구현체를 조합 | + +send와 inbound receipt가 공유하는 account/region/configuration-set/topic identity는 bootstrap의 +canonical binding 한 곳에서 파생한다. outbound에는 `NotificationProviderRuntimeProfile`, +inbound에는 `NotificationReceiptIngressProfile`이라는 최소 slice만 전달하고 두 adapter는 서로 +의존하지 않는다. application validator는 secret/SDK/settings type이 없는 descriptor만 받는다. +`ApplicationContext` 탐색, bean-name reflection, sibling adapter 직접 호출이나 adapter에서 use case +orchestration을 하는 방식은 금지한다. + +### 13.2 route strategy + +```text +RouteStrategy = + SINGLE + FAN_OUT_ALL + ORDERED_FALLBACK +``` + +- `SINGLE`: exactly one target이 compile되어야 한다. +- `FAN_OUT_ALL`: target별 독립 provider leg row를 만들며 partial outcome을 보존한다. +- `ORDERED_FALLBACK`: 앞 target이 authoritative definite-not-accepted일 때만 다음 target을 + 활성화한다. + +provider list만 써놓고 strategy를 추론하지 않는다. 빈 list, duplicate target, channel mismatch, +capability mismatch, cycle 또는 상한 초과는 startup에서 실패한다. + +### 13.3 cross-channel fan-out + +email과 Slack을 모두 보내는 것은 대체로 business escalation/communication policy다. + +```text +feature application: + SECURITY_ALERT_EMAIL intent + SECURITY_ALERT_SLACK intent +``` + +하나의 outbound adapter route가 임의로 channel을 바꾸거나 email failure 뒤 Slack으로 넘어가지 +않는다. cross-channel fallback/escalation은 consent, urgency, duplicate tolerance가 다르므로 +application orchestration이 소유한다. + +### 13.4 fallback 안전 조건 + +다음 outcome만 기본 fallback activation을 허용한다. + +- local validation/rendering에서 provider call 전 definite failure; +- admission/quota 정책이 provider call 전 definite rejection을 증명; +- provider가 contract상 request를 수락하지 않았음을 명시; +- authoritative reconciliation이 not-applied를 반환. + +즉 공통 조건은 `submissionCertainty=DEFINITELY_NOT_APPLIED`다. retry 여부나 fault scope만 보고 +fallback하지 않는다. + +다음은 fallback을 기본 차단한다. + +- timeout; +- connection reset after possible write; +- malformed success response; +- provider accepted 뒤 local persistence 실패; +- provider/card에 reconciliation이 없는 unknown outcome. + +duplicate가 business적으로 허용되는 escalation은 `allowIndeterminateEscalation` 같은 global +boolean이 아니라 검토된 notification-kind policy와 별도 intent로 표현한다. + +### 13.5 amplification budget + +각 route는 다음 상한을 모두 고정한다. + +```text +recipientsPerIntent = exactly 1 +maxTargetsPerRecipient +maxPhysicalAttemptsPerDelivery +maxFallbackActivations +maxReconcileCalls +maxTotalProviderCallsPerIntent +maxElapsedRetryHorizon +``` + +config는 code maximum을 낮출 수만 있다. provider leg 수 × retry × fallback × reconcile의 +최악값이 `maxTotalProviderCallsPerIntent`를 넘으면 startup compiler가 거부한다. + +## 14. template, rendering과 localization + +### 14.1 checked-in immutable template가 baseline이다 + +R2 baseline은 versioned local template asset을 repository에 둔다. + +```text +templates/ + password-reset/ + v3/ + ko-KR/ + email-subject.txt + email-text.txt + email-html.html + en/ + ... + schema.json 또는 code descriptor +``` + +Slack은 JSON string template에 arbitrary substitution하는 방식보다 typed Block Kit model +builder를 사용한다. provider-stored SES template는 optional card이며 local rendering과 다른 +version/lifecycle 계약을 갖는다. + +### 14.2 immutable version + +- 같은 `(templateId, version, locale, asset)` content를 in-place 수정하지 않는다; +- build-time manifest에 SHA-256 checksum, schema version, supported locale과 byte limit을 + 기록한다; +- 변경은 새 version이다; +- active intent가 참조한 version은 retention/retry/receipt window 동안 제거하지 않는다; +- rollout에서 모든 live/retained intent가 참조하는 asset과 renderer/canonical + serialization/escaping revision의 load/checksum을 startup validation한다. + +### 14.3 typed parameter + +최선은 notification kind별 typed record/factory다. + +```java +record PasswordResetTemplateParameters( + DisplayName displayName, + ResetLinkReference resetLink, + ExpiryMinutes expiryMinutes) {} +``` + +공통 engine boundary가 필요하면 closed scalar/value set만 허용한다. + +```text +TemplateValue = + SafeText + TrustedAbsoluteLinkReference + LocalDateValue + LocalDateTimeValue + IntegerValue + MoneyValue +``` + +raw HTML, arbitrary JSON subtree, provider block object, unbounded collection은 기본 parameter가 +아니다. template schema는 unknown/missing parameter를 거부하고 unused parameter도 drift로 +검출한다. + +### 14.4 escaping과 injection + +- email HTML text와 attribute/URL context를 구분해 escape한다; +- email header subject/from/reply-to에는 CR/LF와 control character를 허용하지 않는다; +- Slack mrkdwn/plain_text context를 구분한다; +- raw ``, `<@user>`, link target 삽입은 별도 allowlisted value type만 허용한다; +- untrusted URL은 application이 검증한 opaque link reference에서 adapter가 resolve한다; +- template engine의 reflection, arbitrary method/property access, file/network include를 + 비활성화한다; +- output byte/block/element/depth 제한을 provider limit보다 보수적으로 둔다. + +### 14.5 locale + +locale fallback은 JVM default나 host locale을 사용하지 않는다. + +```text +requested exact locale + -> configured language fallback + -> notification-kind default locale + -> startup-validated default asset +``` + +선택된 locale/fallback result는 plan snapshot에 저장한다. timezone이 필요한 값은 business +policy가 명시한 zone을 사용하며 server default timezone을 사용하지 않는다. + +### 14.6 rendering 시점 + +durable baseline은 encrypted typed parameters와 frozen template reference를 저장하고 dispatch +직전에 render한다. + +장점: + +- rendered body의 장기 저장을 피한다; +- provider별 payload limit/format을 attempt 시점에 적용한다; +- key rotation과 redaction surface를 줄인다. + +단, asset revision은 frozen이어야 하며 render result digest를 attempt에 남겨 같은 plan의 drift를 +검출한다. legal/audit상 exact rendered content 보존이 필요한 kind는 별도 encrypted retention +class와 승인을 요구한다. + +### 14.7 attachment + +attachment와 대용량 inline image는 최소 R2 범위가 아니다. 도입 시 fileserver/object-storage +opaque reference, malware scan, size/content-type, recipient authorization, provider upload +lifecycle을 별도 설계한다. arbitrary byte array나 local path를 notification command에 넣지 +않는다. + +## 15. provider attempt contract + +### 15.1 internal provider SPI + +provider SPI는 adapter-internal type이며 개념적으로 다음 책임을 갖는다. + +```text +descriptor() +prepare(renderedMessage, target, attemptContext) +sendOneAuthorizedAttempt(preparedRequest, attemptExecutionToken, deadline) +reconcile(lookupReference, lookupMode, deadline) [optional] +``` + +`prepare`는 provider validation/size mapping을 수행하되 network side effect를 만들지 않는다. +`sendOneAuthorizedAttempt` 한 번은 coordinator 관점의 한 authorized attempt다. 이름이나 구현으로 +wire-level exactly-once를 암시하지 않는다. + +correlation identity를 생성 시점과 의미에 따라 분리한다. + +```text +AttemptCorrelationId // send 전 생성, opaque/non-PII +ProviderClientOperationKey // provider가 native key를 지원할 때만 +ProviderMessageReference // accepted response/event 뒤에만 획득 +ReconciliationLookupMode // PRE_SEND_CORRELATION | CLIENT_OPERATION_KEY + // | MESSAGE_REFERENCE | UNSUPPORTED +``` + +response-loss에서 아직 없는 `ProviderMessageReference`로 reconcile할 수 있다고 가정하지 않는다. + +### 15.2 provider descriptor + +```text +NotificationProviderDescriptor + providerId + channel + submissionSemantics + nativeIdempotencyCapability + reconciliationCapability + receiptCapability + destinationCapability + templateCapability + hiddenRetryMode + maxPayload/recipient constraints + supportedCredentialMode + preSendCorrelationCapability + reconciliationLookupMode +``` + +descriptor는 marketing label이 아니라 readiness/runtime compiler 입력이다. + +### 15.3 attempt outcome + +provider exception을 그대로 던지거나 모든 exception을 transient로 취급하지 않는다. transmission +certainty, retry/운영 조치와 fault scope를 직교 축으로 유지한다. + +```text +SubmissionCertainty = + DEFINITELY_NOT_APPLIED | PROVIDER_ACCEPTED | INDETERMINATE + +RetryDisposition = + RETRY_AT | PARK_BINDING | TERMINAL | NOT_APPLICABLE + +FaultScope = + DELIVERY | ROUTE_REVISION | PROVIDER_BINDING | ACCOUNT + +ProviderAttemptOutcome( + submissionCertainty, + retryDisposition, + faultScope, + stableReasonCode, + retryNotBefore?, + attemptCorrelationId, + providerMessageReference? +) +``` + +raw response body, raw address, token, SDK exception object는 application으로 나가지 않는다. +fallback은 submission certainty만, retry/parking은 retry disposition과 fault scope만 사용한다. +하나의 `permanent` 값으로 invalid recipient와 account credential failure를 합치지 않는다. + +### 15.4 error classification + +| failure | 기본 분류 | +| --- | --- | +| invalid recipient/content, wire call 전 | `DEFINITELY_NOT_APPLIED + TERMINAL + DELIVERY` | +| local template/renderer revision bug | `DEFINITELY_NOT_APPLIED + PARK_BINDING + ROUTE_REVISION` | +| local admission/rate-limit 거부, wire call 전 | `DEFINITELY_NOT_APPLIED + RETRY_AT + PROVIDER_BINDING` | +| provider explicit throttling이 non-acceptance를 보장 | `DEFINITELY_NOT_APPLIED + RETRY_AT + PROVIDER_BINDING` | +| provider auth/scope/config/account rejection이 non-acceptance를 보장 | `DEFINITELY_NOT_APPLIED + PARK_BINDING + PROVIDER_BINDING/ACCOUNT` | +| timeout/connection loss after possible write | `INDETERMINATE + NOT_APPLICABLE + DELIVERY` | +| success status but response decode/contract 실패 | `INDETERMINATE + NOT_APPLICABLE + DELIVERY` | +| provider accepted response | `PROVIDER_ACCEPTED + NOT_APPLICABLE + DELIVERY` | + +HTTP status 하나만으로 transmission certainty를 일반화하지 않는다. 각 provider card에 exact +response/error mapping table과 protocol test를 둔다. + +`PARK_BINDING`은 `FaultScope`에 대응하는 shared admission gate를 닫고 readiness를 내리며 +backlog를 terminal 유실시키지 않는다. + +```text +NotificationAdmissionGate = + (scopeType, scopeRevision) + + state = ACTIVE | PARKED + + generation + + boundedReasonCode/faultScope + + parkedAt/resumedAt +``` + +attempt finalize transaction은 exact outcome fact를 append하고, gate를 expected generation의 +`ACTIVE -> PARKED`로 CAS하며, 현재 leg를 `PARKED_BINDING`으로 바꾼다. concurrent park는 +idempotent하게 같은/higher generation을 관측한다. 다른 node의 eligible scan과 +`WIRE_AUTHORIZED` transaction은 route/provider/account gate가 모두 ACTIVE일 때만 진행하므로 +restart/multi-instance에서도 park가 유지되고 hot-loop하지 않는다. + +gate park보다 먼저 `WIRE_AUTHORIZED`를 commit한 attempt는 이미 권한을 얻었으므로 bounded +completion/indeterminate protocol을 따른다. park는 새 wire authorization을 막지만 이미 시작한 +provider side effect를 recall한다고 주장하지 않는다. + +route revision 수정 또는 credential/account 복구 후 audited resume use case만 readiness/config를 +재검증하고 generation을 증가시켜 ACTIVE로 바꾼다. 같은 transaction/bounded batch에서 parked +leg의 expiry, cancellation, policy/suppression과 attempt budget을 다시 판단해 `QUEUED`, +`EXPIRED` 또는 `POLICY_REJECTED`로 이동한다. initial R2는 park를 fallback activation으로 +해석하지 않는다. + +### 15.5 retry ownership + +coordinator가 다음을 소유한다. + +- attempt authorization; +- attempt ordinal과 total count; +- absolute attempt deadline; +- retry horizon/expiry; +- full-jitter backoff; +- bounded provider `Retry-After`; +- provider/card별 definite/indeterminate 분류; +- fallback activation; +- reconciliation budget. + +SDK default retry는 baseline에서 끈다. SDK를 끌 수 없으면 callback/interceptor로 모든 physical +wire attempt가 attempt journal과 total budget에 계수됨을 증명할 때만 provider card를 승인한다. + +`AttemptCorrelationId`와 지원되는 `ProviderClientOperationKey`는 같은 attempt 동안 안정적으로 +유지한다. `ProviderMessageReference`는 응답/event가 준 뒤에만 저장한다. provider의 documented +idempotency retention보다 local retry horizon이 길면 그 조합은 safe-retry capability가 아니다. + +### 15.6 cancellation + +deadline/cancellation은 local wait를 멈추는 신호이지 provider side effect rollback 증거가 아니다. +wire call 시작 뒤 cancellation되면 card가 definite-not-sent를 증명하지 않는 한 +`TERMINAL_INDETERMINATE` 또는 reconcile path다. thread interrupt만으로 “전송되지 않음”을 +주장하지 않는다. + +## 16. durable persistence와 worker protocol + +### 16.1 기준 topology + +최소 R2는 business source-of-truth와 notification journal이 같은 PostgreSQL transaction manager에 +참여할 수 있다는 가정에 기반한다. + +```text +business application transaction + -> business state write + -> NotificationIntentAppendPort + -> intent + frozen deliveries insert + -> commit + +dispatcher + -> claim in short DB transaction + -> commit claim + -> reserve attempt and commit WIRE_AUTHORIZED after final eligibility recheck + -> render/provider call outside DB transaction + -> append attempt result terminal-once + -> merge delivery projection in short token/version-guarded transaction +``` + +business DB와 journal DB가 다르면 이 원자성은 성립하지 않는다. 그 경우 generic outbox -> broker +-> inbound consumer/inbox topology를 별도 설계하고 현재 R2 baseline이라고 부르지 않는다. +`NotificationIntentAppendPort` 구현은 caller의 REQUIRED transaction에 참여하고 `REQUIRES_NEW`를 +사용하지 않는다. `NotificationDispatchUseCase`의 capability/transaction shape는 §11.4를 +정본으로 한다. + +### 16.2 `notification_intent` + +개념 column: + +```text +notification_id +tenant_scope_digest +notification_kind +channel +route_id +mode +admission_class +source_operation_digest +source_operation_hmac_key_version +idempotency_key_digest/key_version +intent_fingerprint +intent_fingerprint_key_version +policy_revision +route_plan_revision +template_id/version/checksum +renderer/serialization/escaping_revision +locale +recipient_ciphertext/nonce/algorithm/key_ref/key_version +parameter_ciphertext/nonce/algorithm/key_ref/key_version +not_before +expires_at +retention_class +created_at +summary_state/version +``` + +원칙: + +- immutable ciphertext와 crypto metadata를 우선한다; +- summary state는 delivery에서 검증 가능한 projection이다; +- same idempotency digest + same fingerprint는 기존 intent를 반환한다; +- same idempotency digest + different fingerprint는 permanent mismatch다; +- recipient/content plaintext index를 만들지 않는다. + +### 16.3 `notification_delivery_leg` + +```text +delivery_id +notification_id +target_ordinal +strategy_group/strategy_ordinal +opaque_target_ref +provider_binding_revision +state +submission_projection +recipient_transport_projection +abuse_projection +conversation_presence_projection +claim_owner_token +claim_lease_until +row_version +next_action_at +attempt_count +reconcile_count +attempt_correlation_digest/key_version +provider_client_operation_key_digest/key_version [optional] +provider_message_reference_ciphertext/nonce/key_ref/key_version [optional] +provider_message_reference_digest/hmac_key_version [optional] +last_reason_code +accepted_at +terminal_at +``` + +이 row는 recipient row가 아니라 provider leg다. fan-out target마다 별도 row를 만든다. +fallback target은 처음부터 frozen하되 첫 target만 `QUEUED`, 나머지는 `BLOCKED`로 둔다. +`PARKED_BINDING`은 `next_action_at=null`이며 gate가 audited resume되기 전 eligible scan에 +나타나지 않는다. + +### 16.4 `notification_attempt` + +append 중심의 physical evidence: + +```text +attempt_id +delivery_id +attempt_ordinal +attempt_execution_token +reserved_at +wire_authorized_at +attempt_deadline +transport_finalize_grace_until +completed_at +render_hmac/hmac_key_version +provider_binding_revision +credential_generation +authorized_admission_gate_generations +transmission_phase +submission_certainty +retry_disposition +fault_scope +stable_reason_code +attempt_correlation_id +provider_client_operation_key_digest/key_version [optional] +provider_message_reference_ciphertext/digest/key_versions [optional] +``` + +raw provider payload/error response는 저장하지 않는다. credential value가 아니라 bounded +generation/reference만 기록한다. execution token별 exact provider response/result fact는 최대 +하나만 기록한다. reaper의 deadline-expired/unknown observation은 별도 immutable fact이며 exact +response slot을 선점하지 않는다. + +### 16.5 `notification_receipt_event` + +```text +receipt_event_id +provider +provider_account_scope +outer_transport_message_id_digest/hmac_key_version +provider_event_id_digest/hmac_key_version +semantic_event_fingerprint/hmac_key_version +attempt_correlation_digest/hmac_key_version [optional] +provider_message_reference_digest/hmac_key_version [optional] +normalized_event_type +provider_occurred_at +server_received_at +verification_key_revision +state = ORPHAN | APPLIED | DUPLICATE | CONFLICT | QUARANTINED +encrypted_short_lived_evidence [optional] +retention_deadline +``` + +callback이 provider accepted DB update보다 먼저 도착할 수 있으므로 매칭되지 않은 verified +receipt를 버리지 않는다. `ORPHAN` inbox에 bounded하게 저장하고 later attach한다. + +### 16.6 technical suppression table + +email hard bounce/complaint 등 provider lifecycle로 생긴 suppression은 별도 table/port로 둔다. + +```text +channel +recipient_hmac/key_version +scope +reason +source_provider +effective_at +expires_at/null +evidence_ref +version +``` + +business unsubscribe/consent와 합치지 않는다. dispatch 전에 application business eligibility와 +technical suppression을 각각 평가한다. + +### 16.7 `notification_admission_gate` + +multi-instance park/resume의 정본은 process memory나 health cache가 아니라 같은 PostgreSQL의 +shared table이다. + +```text +scope_type = ROUTE_REVISION | PROVIDER_BINDING | ACCOUNT +scope_revision +state = ACTIVE | PARKED +generation +fault_scope +bounded_reason_code +parked_at +resumed_at +row_version +``` + +`(scope_type, scope_revision)`이 PK다. provider leg는 frozen route/provider/account scope를 통해 +필요한 gate를 결정한다. claim eligibility query는 모든 관련 gate가 ACTIVE인 row만 고르고, +`WIRE_AUTHORIZED` CAS는 읽은 gate generation이 그대로 ACTIVE인지 다시 검증한다. + +### 16.7.1 route writer fence와 legacy permit + +rolling cutover 중 legacy synchronous send와 canonical intent admission이 같은 route를 동시에 +받지 않도록, 같은 PostgreSQL에 route writer fence와 bounded legacy permit을 둔다. process-local +boolean이나 배포 순서만으로 single-writer를 주장하지 않는다. + +`notification_route_writer_fence`: + +```text +route_revision PK +owner = LEGACY | CANONICAL +state = ACTIVE | DRAINING +generation +row_version +last_operation_token [optional denormalized FK] +draining_started_at [optional] +switched_at [optional] +``` + +`notification_writer_operation`: + +```text +operation_token PK +operation_sequence UNIQUE [shared cutover sequence, DB-assigned after route fence/global lock] +action = INITIALIZE_LEGACY | INITIALIZE_CANONICAL_FRESH + | BEGIN_DRAIN | TERMINALIZE_EXPIRED_PERMITS | COMPLETE_SWITCH | ABORT_DRAIN +route_set_digest +request_input_digest [server-canonical, never caller-supplied] +fresh_installation_provenance_token [INITIALIZE_CANONICAL_FRESH only] +actor_digest +reason_code +recorded_at [post-lock clock_timestamp(); observation only, not physical commit time] +``` + +`notification_writer_operation_route`: + +```text +operation_token FK +route_revision +expected_owner [server-derived; optional only for the two INITIALIZE actions] +expected_generation [optional only for the two INITIALIZE actions] +result_owner +result_state +result_generation +drain_begin_operation_token [required for TERMINALIZE/COMPLETE/ABORT] +reviewed_old_node_count [BEGIN_DRAIN only] +reviewed_old_node_set_digest [BEGIN_DRAIN only] +reviewed_inventory_manifest_digest [BEGIN_DRAIN only] +transport_proof_requirement = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED +transport_proof_registry_digest +quiescence_attestation_token [optional except required COMPLETE for unproven transport] +blocking_permit_set_digest [paired with attestation] +affected_permit_count [TERMINALIZE_EXPIRED_PERMITS only] +affected_permit_set_digest [TERMINALIZE_EXPIRED_PERMITS only] +requested_batch_bound [TERMINALIZE_EXPIRED_PERMITS only] +PK (operation_token, route_revision) +``` + +`notification_writer_transport_proof_registry`: + +```text +route_revision +transport_profile_revision +admission_role = ACTIVE | RETIRING +transport_proof_class = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED +transport_proof_evidence_revision +route_registry_digest +initialization_operation_token +created_at +PK (route_revision, transport_profile_revision) +FK (initialization_operation_token, route_revision) + -> notification_writer_operation_route(operation_token, route_revision) +``` + +`notification_route_writer_permit`: + +```text +permit_token PK +route_revision FK +owner = LEGACY +fence_generation +transport_profile_revision +transport_proof_class = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED +transport_proof_evidence_revision +state = ACTIVE | RELEASED | EXPIRED_PROVEN | TIMED_OUT_UNPROVEN +holder_instance_digest +acquired_at +wire_deadline_at +expires_at +released_at [optional] +terminalized_at [optional] +terminalization_operation_token [required for EXPIRED_PROVEN | TIMED_OUT_UNPROVEN] +row_version +``` + +`notification_writer_quiescence_attestation`: + +```text +attestation_token PK +attestation_sequence UNIQUE [same cutover sequence, assigned after route fence lock] +route_revision +draining_fence_generation +drain_begin_operation_token +canonical_signed_payload +canonical_signed_payload_digest +signature_algorithm = ED25519 +detached_signature +issuer_identity_digest +issuer_key_revision +issuer_public_key_spki +issuer_public_key_digest +trust_snapshot_canonical_payload +trust_snapshot_digest +acceptance_window_profile_revision +allowed_clock_skew_ms +acceptance_margin_ms +issued_at +expires_at +server_verified = true +server_verified_at +server_verifier_revision +environment_identity_digest +database_system_identifier_digest +database_identity_digest +pre_artifact_digest +deployment_revision_digest +transport_profile_set_digest +transport_proof_registry_digest +blocking_permit_count +blocking_permit_set_digest +permit_holder_count +permit_holder_set_digest +consumer_inventory_identity_digest +consumer_inventory_snapshot_digest +consumer_count = 0 +old_node_count +old_node_set_digest [server-derived from the frozen BEGIN inventory] +old_nodes_quiesced_and_irreversibly_fenced = true +old_node_fence_evidence_set_digest +provider_call_ledger_identity_digest +provider_call_ledger_snapshot_digest +provider_call_ledger_open_count = 0 +quiescence_evidence_manifest_digest +evidence_digest +actor_digest +reason_code +observed_at +``` + +attestation은 일반 switch request body의 boolean이 아니다. authenticated +`POST /api/admin/notifications/routes/{routeId}/writer-quiescence-attestations`가 +`notification:cutover-attest` permission의 method-security-proxied application operation을 +호출한다. request는 reviewed drain generation, opaque attestation token과 독립 deployment +inventory issuer가 서명한 quiescence evidence manifest를 제공할 뿐 old-node/permit/zero-fact +digest를 권위 있게 주장하지 못한다. manifest는 exact environment/DB identity, route/drain +generation, PRE artifact/deployment revision, BEGIN에서 동결한 complete bridge-node inventory, +각 node의 retired/quiesced fact와 재시작을 막는 deployment-generation tombstone 및 legacy +credential/egress의 irreversible revocation, production consumer inventory +identity/snapshot/count 0, provider-call ledger identity/snapshot/open-count 0, 발급/만료 시각과 +issuer/trust snapshot, acceptance-window profile revision, bounded `allowed_clock_skew_ms`와 +minimum `acceptance_margin_ms`를 함께 서명한다. server는 reviewed trust catalog로 Ed25519를 +검증한 뒤 actor, post-lock DB `clock_timestamp()`인 `server_verified_at`이 +`issued_at - allowed_clock_skew <= server_verified_at +<= expires_at - acceptance_margin`인 bounded acceptance window, persisted +transport-proof registry, locked permit set/count/digest와 permit의 distinct +`holder_instance_digest` set을 derive한다. 검증한 canonical payload bytes, detached signature, +issuer identity/key, bounded canonical public-key SPKI bytes/digest, canonical trust-snapshot +payload/digest, issued/expiry/server-verified metadata를 summary와 같은 immutable root row에 +함께 보존한다. frozen BEGIN inventory와 manifest node set은 +exact equality여야 하고 permit holder set은 그 inventory의 subset이어야 한다. omitted permit +holder, extra/omitted inventory node, caller-only digest, unknown +issuer/key/environment/DB/artifact/consumer-inventory/provider-ledger identity 또는 snapshot은 +mutation 없이 거부한다. PRE composition/readiness는 registry와 compiled cutover catalog의 exact +equality를 먼저 검증한다. attestation 기록 시 ACTIVE permit가 남은 상태, nonzero/false fact, +acceptance window 밖의 authorization, token mismatch는 root transaction에서 mutation 없이 +거부하며 success response는 physical commit 뒤에만 쓴다. + +`notification_writer_drain_inventory_manifest`는 BEGIN에서 검증한 signed inventory manifest의 +retained header다. + +```text +drain_begin_operation_token +route_revision +expected_fence_generation +canonical_signed_payload +canonical_signed_payload_digest +signature_algorithm = ED25519 +detached_signature +issuer_identity_digest +issuer_key_revision +issuer_public_key_spki +issuer_public_key_digest +trust_snapshot_canonical_payload +trust_snapshot_digest +acceptance_window_profile_revision +allowed_clock_skew_ms +acceptance_margin_ms +issued_at +expires_at +server_verified = true +server_verified_at +server_verifier_revision +environment_identity_digest +database_system_identifier_digest +database_identity_digest +pre_artifact_digest +deployment_revision_digest +consumer_inventory_identity_digest +consumer_inventory_snapshot_digest +provider_call_ledger_identity_digest +provider_call_ledger_snapshot_digest +old_node_count +old_node_set_digest +inventory_manifest_digest +PK (drain_begin_operation_token, route_revision) +FK (drain_begin_operation_token, route_revision) + -> notification_writer_operation_route(operation_token, route_revision) +``` + +`notification_writer_drain_node_inventory`는 BEGIN에서 server가 검증한 complete old-writer +inventory를 digest만이 아니라 row set으로 동결한다. + +```text +drain_begin_operation_token +route_revision +node_instance_digest +transport_profile_revision +deployment_revision_digest +credential_generation_digest +inventory_manifest_digest +inventory_issuer_key_revision +PK (drain_begin_operation_token, route_revision, node_instance_digest) +FK (drain_begin_operation_token, route_revision) + -> notification_writer_drain_inventory_manifest(drain_begin_operation_token, route_revision) +``` + +BEGIN request는 caller-written node digest 대신 short-lived signed inventory manifest와 opaque +token을 전달한다. 별도 trusted deployment inventory issuer가 exact environment/DB/route, +PRE artifact/deployment revision, complete bridge-node set, consumer inventory +identity/snapshot과 provider-call ledger identity/snapshot을 서명한다. application verifier가 +Ed25519 signature/key revision/trust snapshot/acceptance window와 compiled PRE deployment +identity를 검증하고 canonical row set/count/digest를 만든다. BEGIN child, exact 한 retained +manifest header와 모든 inventory row는 fence CAS와 같은 root transaction에서 insert되며 +UPDATE/DELETE가 금지된다. old-node set이 0개여도 count 0과 canonical empty-set digest를 가진 +signed header 한 건은 반드시 보존한다. 따라서 node row 0개는 허용하지만 BEGIN header 0개는 +허용하지 않는다. manifest에 없지만 permit history에 나타나는 holder, manifest의 +duplicate/unknown node/profile, 서명·환경·DB·artifact/consumer-inventory/provider-ledger +identity 또는 snapshot mismatch는 BEGIN 또는 attestation을 fail closed한다. +inventory manifest도 signed acceptance-window profile/skew/margin을 같은 방식으로 검증하며, +negative/out-of-policy bound, unknown profile 또는 minimum remaining validity 미달은 mutation +없이 거부한다. +“quiesced”는 순간적인 process count 0이 아니다. quiescence manifest의 각 node는 deployment +control-plane이 그 exact instance/deployment generation의 재시작을 금지한 tombstone과 old +transport credential generation 또는 egress identity의 irreversible revocation을 함께 가져야 +한다. canonical credential을 공유해 독립적으로 폐기할 수 없거나 revocation을 되돌릴 수 있거나 +paused process가 기존 credential/connection으로 다시 provider I/O를 시작할 수 있으면 issuer는 +서명할 수 없고 route는 +`QUIESCENCE_REQUIRED/NOT_QUALIFIED`로 DRAINING에 남는다. + +`notification_writer_quiescence_node_evidence`는 verified manifest에서 parse한 per-node +irreversible fence를 보존한다. + +```text +attestation_token +route_revision +drain_begin_operation_token +node_instance_digest +deployment_generation_tombstone_digest +legacy_credential_or_egress_revocation_digest +node_evidence_digest +PK (attestation_token, route_revision, node_instance_digest) +FK (attestation_token, route_revision) -> attestation +FK (drain_begin_operation_token, route_revision, node_instance_digest) + -> notification_writer_drain_node_inventory +``` + +이 row set의 node key는 BEGIN inventory와 exact equality이고 canonical sorted digest는 +attestation의 `old_node_fence_evidence_set_digest`와 같아야 한다. attestation root transaction만 +insert하며 UPDATE/DELETE를 금지한다. overall manifest digest만 저장하고 per-node revocation +coverage를 버리지 않는다. +두 manifest는 raw JSON serialization을 서명하지 않는다. domain-separated +`writer-inventory-manifest-v1` / `writer-quiescence-manifest-v1` length-prefixed canonical field +encoding과 sorted bounded node/fact row set을 Ed25519로 서명하며, verifier는 unknown/duplicate +field, non-canonical order/encoding, oversized set과 algorithm/key downgrade를 거부한다. node, +environment와 ledger identity는 opaque digest이고 PII/credential을 포함하지 않는다. +issuer key ID나 request가 동봉한 임의 public key는 trust anchor가 아니다. reviewed artifact의 +closed trust catalog가 허용 key ID, bounded canonical Ed25519 SPKI bytes/digest, +trust-snapshot digest, current/retiring issuance window와 historical-verification +`ALLOW|REVOKED` 판정을 고정한다. write verifier는 retained SPKI bytes의 digest와 catalog +material을 대조한 뒤 그 key로 signature를 검증한다. startup/COMPLETE verifier도 retained SPKI +bytes로 signature를 다시 검증하고 현재 closed catalog가 exact issuer/key/trust-snapshot digest를 +historical `ALLOW`로 승인하는지 별도로 확인한다. 둘 중 하나라도 실패하거나 catalog가 +`REVOKED`면 fail closed한다. +inventory/quiescence issuer는 application 운영 주체와 분리된 external infrastructure +authority다. production artifact, container, database와 environment에는 issuer private key를 +두지 않는다. deterministic local issuer는 test fixture와 `LOCAL_TEST` evidence grade에서만 +허용하고 production cutover/readiness는 거부한다. + +inventory/attestation의 `issued_at..expires_at`은 evidence를 처음 수락할 수 있는 창이지, +이미 수락한 irreversible fact의 임대 시간이 아니다. Java write verifier가 그 창 안에서 서명과 +trust snapshot을 검증하고 immutable header/row set을 root-commit한 뒤에는 +inventory snapshot과, attestation의 deployment-generation tombstone, legacy credential/egress +irreversible revocation, consumer inventory 0과 provider-call ledger 0 snapshot은 시간이 지나도 +당시의 불변 사실로 남는다. +`COMPLETE_SWITCH`는 stored canonical payload/signature를 Java에서 다시 Ed25519 검증하고 BEGIN +inventory와 per-node irreversible fence exact equality, DRAINING이라 새 permit을 만들 수 없는 +상태와 ACTIVE permit 0, selected registry/ledger identity 및 snapshot equality를 같은 root +transaction에서 lock/recompute한다. attestation의 현재 만료 여부를 다시 묻지 않으며, +constraint timing을 바꾸는 `SET CONSTRAINTS`로 우회할 correctness dependency도 존재하지 않는다. +CAS, operation append와 결과는 한 physical commit으로 원자화하고 acknowledgement 뒤에만 +success를 반환한다. + +`notification_fresh_installation_provenance`는 empty database가 “legacy가 존재한 적 없는 +fresh provisioning”임을 입증하는 별도 immutable authority다. 순간적인 zero snapshot만으로는 +이 authority를 만들 수 없다. external infrastructure issuer는 먼저 exact database resource와 +birth certificate를 대상으로 영구적이고 비가역적인 control-plane +`no-legacy-authority fence`를 다음 순서로 완성해야 한다. + +1. 모든 reviewed legacy deployment generation deny/tombstone, 해당 DB와 provider credential의 + legacy-scoped 신규 발급 disable과 기존 legacy credential revoke, legacy DB ingress와 + provider egress의 + established-flow 차단을 먼저 irreversible enforcement revision으로 commit하고 + read-after-write한다. +2. 그 enforcement가 활성화된 뒤 legacy identity의 existing DB session과 provider + connection/flow를 강제 종료한다. application workload/business consumer/legacy node + inventory, DB open session, provider open flow와 provider-call ledger entry/open count가 모두 + 0인 post-enforcement manifest를 관측한다. 각 source evidence는 fence token과 enforcement + revision을 참조하고 그 read-back보다 같거나 뒤인 causal revision/time을 가져야 한다. + provider ledger snapshot cut은 모든 flow termination acknowledgement보다 뒤여야 하고 + accepted/pending/indeterminate count가 모두 0이어야 한다. provider가 그 authoritative settled + cut을 증명하지 못하면 fresh authorization을 발급하지 않는다. +3. issuer control-plane ledger가 위 enforcement와 post-enforcement zero/termination manifest를 + 하나의 permanent fence token/revision/digest로 seal-commit하고 + `committed_at`/`irreversible=true`를 read-after-write한다. 그 뒤에만 DB-birth authorization을 + 서명한다. + +zero 관측 뒤 deny를 활성화하거나, enforcement와 zero manifest 사이의 causal binding이 없는 +snapshot을 사후 조합하는 것은 금지한다. credential revoke만으로 cached authority를 회수했다고 +추론하지 않으며 fence를 해제하거나 같은 resource에 legacy authority를 다시 발급하는 operation은 +존재하지 않는다. 재시도가 필요하면 새 database resource와 새 birth certificate를 사용한다. +따라서 authorization 발급과 DB provisioning commit 사이에 old application workload나 legacy +node가 시작·재개해 DB/provider authority를 다시 얻거나 cached session/connection을 재사용할 수 +없고, paused old client가 resume해도 provider I/O는 0이다. + +```text +provenance_token PK +fresh_initialization_operation_token UNIQUE +canonical_signed_payload +canonical_signed_payload_digest +signature_algorithm = ED25519 +detached_signature +issuer_identity_digest +issuer_key_revision +issuer_public_key_spki +issuer_public_key_digest +trust_snapshot_canonical_payload +trust_snapshot_digest +acceptance_window_profile_revision +allowed_clock_skew_ms +acceptance_margin_ms +issued_at +expires_at +server_verified = true +server_verified_at +server_verifier_revision +database_resource_canonical_payload +database_resource_identity_digest +database_birth_certificate_canonical_payload +database_birth_certificate_digest +database_system_identifier_digest +database_identity_digest +schema_identity_digest +environment_identity_digest +final_artifact_digest +canonical_route_set_digest +application_workload_inventory_count = 0 +application_workload_inventory_digest +business_consumer_inventory_count = 0 +business_consumer_inventory_digest +legacy_node_inventory_count = 0 +legacy_node_inventory_digest +provider_call_ledger_identity_digest +provider_call_ledger_snapshot_digest +provider_call_ledger_snapshot_cut_revision +provider_call_ledger_snapshot_cut_at +provider_call_ledger_entry_count = 0 +provider_call_ledger_open_count = 0 +provider_call_ledger_indeterminate_count = 0 +no_legacy_authority_fence_token +no_legacy_authority_fence_revision +no_legacy_authority_fence_canonical_payload +no_legacy_authority_fence_digest +no_legacy_authority_fence_committed_at +no_legacy_authority_fence_read_back_at +no_legacy_authority_fence_irreversible = true +no_legacy_authority_enforcement_revision +no_legacy_authority_enforcement_digest +no_legacy_authority_enforcement_activated_at +no_legacy_authority_enforcement_read_back_at +post_enforcement_zero_manifest_canonical_payload +post_enforcement_zero_manifest_digest +post_enforcement_zero_observation_revision +post_enforcement_zero_observed_at +legacy_deployment_generation_deny_set_digest +legacy_deployment_generation_tombstone_set_digest +legacy_database_credential_issuance_disabled = true +legacy_database_credential_revocation_set_digest +legacy_database_credential_revocation_complete = true +legacy_database_session_inventory_digest +legacy_database_session_open_count = 0 +legacy_database_session_termination_evidence_digest +legacy_database_ingress_denied = true +legacy_database_ingress_denial_policy_digest +legacy_database_ingress_blocks_established_flows = true +legacy_provider_credential_issuance_disabled = true +legacy_provider_credential_revocation_set_digest +legacy_provider_credential_revocation_complete = true +legacy_provider_connection_flow_inventory_digest +legacy_provider_connection_flow_open_count = 0 +legacy_provider_connection_flow_termination_evidence_digest +provider_egress_denied = true +provider_egress_denial_policy_digest +provider_egress_blocks_established_flows = true +authorization_digest +``` + +`notification_writer_finalization_discriminator`는 FINAL database의 closed state를 보존한다. + +```text +singleton_key = NOTIFICATION_FINALIZATION +state = AWAITING_SIGNED_FRESH_PROVISIONING + | FRESH_PROVISIONED + | UPGRADE_VALIDATED +fresh_provenance_token [FRESH_PROVISIONED only] +validated_upgrade_history_digest [UPGRADE_VALIDATED only] +state_operation_token +row_version +``` + +V8은 schema/history 생성 전의 provenance를 요구하거나 생성하지 않는다. migration은 먼저 +schema shape를 additive하게 만든 뒤 다음 두 입력만 분류한다. + +- complete upgrade history와 exact canonical fence set이 있으면 전체 retained history를 + structural validation하고 그대로 보존한 뒤 discriminator를 `UPGRADE_VALIDATED`와 validated + history digest로 원자 기록한다; +- notification control/data-plane table과 fence/journal/provenance가 완전히 비었으면 canonical + fence나 initialization history를 seed하지 않고 discriminator만 + `AWAITING_SIGNED_FRESH_PROVISIONING`으로 둔다. V8 재실행은 이 singleton과 나머지 empty state만 + idempotent하게 허용한다. + +fence가 없는데 다른 notification row가 하나라도 있거나 partial/extra fence, incomplete operation +history, provenance 선행 삽입 또는 discriminator와 store shape mismatch가 있으면 migration은 +fail closed한다. V8 SQL은 payload/signature의 non-null·bounded length, digest/count/FK, +nonnegative reviewed skew/margin과 +`no_legacy_authority_enforcement_activated_at +<= no_legacy_authority_enforcement_read_back_at +<= provider_call_ledger_snapshot_cut_at +<= post_enforcement_zero_observed_at +<= no_legacy_authority_fence_committed_at +<= no_legacy_authority_fence_read_back_at +<= issued_at`, +`issued_at - allowed_clock_skew <= server_verified_at +<= expires_at - acceptance_margin` 같은 structural integrity만 검증한다. SQL이 +Ed25519 또는 trust validity를 검증했다고 주장하지 않는다. + +별도 post-migration deployment operation `notificationFreshProvisioning`만 +`AWAITING_SIGNED_FRESH_PROVISIONING`을 끝낼 수 있다. normal runtime startup이 아니라 격리된 +provisioning job이 위 fence를 먼저 commit한 independent infrastructure issuer의 short-lived +signed DB-birth authorization을 받는다. domain-separated +`notification-fresh-provisioning-v1` canonical payload는 exact database resource와 birth +certificate, environment/DB-system/database/schema identity, FINAL artifact digest, canonical +route set, application workload/business consumer/legacy node inventory 각각의 count 0과 canonical +empty-set digest, provider-call ledger identity/snapshot/cut revision/time과 +entry/open/indeterminate count 0, nonce, +deterministic operation token, acceptance-window profile revision, bounded allowed clock skew와 +minimum remaining validity margin을 묶는다. 또한 no-legacy-authority fence의 +token/revision/canonical payload/digest/committed-at/read-back-at/`irreversible=true`, legacy +deployment-generation deny/tombstone set, DB/provider credential issuance-disable와 revocation +set, legacy DB-session termination/open-count 0과 established-flow-blocking ingress policy, +legacy provider connection/flow termination/open-count 0과 established-flow-blocking +provider-egress deny policy를 모두 묶는다. irreversible enforcement +revision/digest/activated-at/read-back-at과, 그 revision 뒤의 zero/termination manifest canonical +payload/digest/observation revision/observed-at도 묶는다. 위 필드는 signed payload와 retained +provenance 양쪽에 exact value로 보존하며 digest만 남기고 원본 authority를 버리지 않는다. Java +verifier는 각 inventory/session/flow/ledger source evidence가 exact fence token과 enforcement +revision의 causal descendant인지, 위 timestamp 순서와 final fence revision/token read-back이 +일치하는지 검증해 pre-fence zero snapshot, sign-before-seal과 cross-revision 조합을 거부한다. +여기서 credential disable/revoke와 ingress/egress deny의 namespace는 signed legacy +deployment-generation set이다. reviewed provisioner와 이후 canonical runtime identity를 +legacy authority로 분류하거나 그 credential 발급을 암묵적으로 허용/차단하지 않는다. + +provisioning은 하나의 physical connection과 하나의 provisioner root transaction에서 반드시 +다음 순서로 실행한다. + +1. exact `SECURITY DEFINER` snapshot/read-lock function + `notification_fresh_provisioning_snapshot_and_lock`이 finalization discriminator, notification + control/data-plane emptiness, DB resource/system/database/schema identity와 existing same-token + result를 lock하고 bounded typed snapshot 및 DB-computed semantic digest를 반환한다. 이 + function은 mutation하지 않으며 lock은 physical commit/rollback까지 유지된다. +2. Java `NotificationFreshProvisioningAuthorizationVerifierPort` 구현이 같은 transaction을 + 열린 채 domain-separated canonical payload, Ed25519 signature, retained issuer SPKI + bytes/digest, artifact closed trust snapshot, issuer/key historical policy, birth certificate와 + committed irreversible fence evidence를 검증하고, 1단계 snapshot과 signed semantic value의 + exact equality를 확인한다. +3. exact `SECURITY DEFINER` apply function `notification_fresh_provisioning_apply`가 같은 + connection/transaction에서만 호출된다. apply는 1단계 lock ownership과 snapshot digest, + new-mutation branch의 AWAITING discriminator와 store emptiness, DB identity, operation + token 및 canonical payload semantic digest를 DB-owned value로 다시 계산·비교한다. 이어 새 + `clock_timestamp()` 값을 한 번 읽어 + `issued_at - allowed_clock_skew <= apply_now <= expires_at - acceptance_margin`을 다시 + 검증하고, 그 exact `apply_now`를 provenance의 `server_verified_at`과 + `INITIALIZE_CANONICAL_FRESH` operation header의 `recorded_at`에 함께 저장한다. 그 뒤에만 + retained provenance, 모든 route child, reviewed initial `ACTIVE/CANONICAL` fence를 insert하고 + discriminator를 `FRESH_PROVISIONED`로 전이한다. + +Java 검증 뒤 process가 pause되어 acceptance window를 벗어나면 3단계 fresh DB-time 검사가 +DML 전에 실패하고 root transaction 전체가 rollback되어 mutation은 0이다. apply statement가 +성공한 시점이 DB-birth authorization 수락의 linearization point이고 row visibility/durability는 +physical commit에서 생긴다. apply 뒤 commit이 지연되어 window가 지나더라도 issuer가 서명 전에 +commit한 birth/fence fact가 영구·비가역이고 그 사이 legacy authority가 부활할 수 없으므로 +safety는 유지된다. commit failure는 mutation과 success response가 0이며 success는 physical +commit acknowledgement 뒤에만 반환한다. rollback 뒤 retry는 두 함수와 Java 검증을 처음부터 +다시 거친다. 같은 token/input replay는 stored result를 반환하고 +token/input/identity/payload-digest mismatch는 mutation 없이 실패한다. + +same-token committed replay는 새 authorization acceptance가 아닌 read-only result recovery +branch다. snapshot function이 exact `FRESH_PROVISIONED` discriminator/provenance/init/fence +equality를 lock/recompute하고 Java가 stored signature/trust/semantic fact와 original +`server_verified_at` acceptance를 다시 검증한 경우에만 apply가 mutation 없이 stored result를 +반환한다. 이 branch는 current wall-clock expiry를 다시 적용하지 않는다. 기존 result가 없는 +첫 apply, partial/mixed result 또는 다른 input/token은 반드시 new-mutation branch를 타거나 +실패하므로 Java 검증 뒤 expiry pause를 우회하지 못한다. + +SQL은 lock, identity, state, exact-set/digest, time-window와 atomic write의 structural +authority일 뿐 Ed25519/trust authority가 아니다. cryptographic authority는 Java verifier +port에 있다. provisioner principal은 위 두 함수의 `EXECUTE`만 가지며 notification table의 +generic `SELECT|INSERT|UPDATE|DELETE`, sequence `USAGE`, DDL과 다른 function `EXECUTE`는 모두 +0이다. provisioner credential을 탈취한 주체가 Java 검증을 건너뛰고 structurally well-formed지만 +forged/invalid payload로 apply를 직접 호출해 row를 commit하더라도 그 row 자체는 readiness +authority가 아니다. 아래 mandatory FINAL read use case의 Java Ed25519/trust/semantic 재검증이 +성공하기 전에는 readiness, canonical admission, worker와 provider I/O가 모두 0이고 mismatch는 +dark/`NOT_QUALIFIED`다. 반대로 valid signed authorization을 직접 apply하더라도 서명 전에 +commit된 irreversible fence fact와 payload가 이미 결합되어 있으며 apply의 lock/state/DB +identity/payload digest/fresh DB-time 검사를 우회할 수 없다. out-of-band DB tampering이나 +constraint bypass까지 관측되면 복구 가능한 authorization으로 추론하지 않고 fail-closed +availability/integrity incident로 격리한다. + +AWAITING 동안 normal startup/readiness는 dark이며 canonical admission, claim, worker와 provider +I/O가 모두 0이다. provisioning commit 뒤를 포함한 모든 FINAL startup/readiness read path는 +`NotificationFinalizationEvidenceReadUseCase`(application read use case) +`-> NotificationFinalizationRetainedEvidenceQueryPort` +`-> PostgreSQL read-only persistence adapter` +`-> NotificationFinalizationEvidenceVerifierPort`의 seam만 사용한다. query adapter는 한 +read-only consistent transaction의 bounded snapshot을 application-owned immutable projection으로 +반환하며 persistence entity나 Spring Data type을 application으로 유출하지 않는다. row/set이 +reviewed bound를 넘으면 truncate하지 않고 fail closed한다. 마지막 단계는 application use case가 +반환된 projection을 verifier port로 넘기는 orchestration이며 persistence adapter가 verifier +implementation에 의존하거나 직접 호출한다는 뜻이 아니다. + +FRESH projection은 discriminator, full provenance, exact +`INITIALIZE_CANONICAL_FRESH` header/route child와 canonical fence set을 읽는다. UPGRADE +projection은 discriminator와 full operation header/route child, transport-proof registry, +permit, fence, signed BEGIN inventory header/node child, selected·superseded·unselected를 포함한 +모든 retained attestation header와 그 permit/holder/node/revocation/consumer/provider-ledger +child row를 읽는다. Java verifier port는 +branch별 canonical payload/signature, bounded issuer SPKI bytes/digest, retained trust-snapshot +payload/digest와 current closed catalog의 historical `ALLOW|REVOKED` 판정을 다시 검증하고, +DB identity, birth/fence facts, discriminator, operation/registry/permit/inventory/attestation 및 +route/fence semantic exact equality를 재계산한다. BEGIN-less/orphan attestation, closing +operation 뒤의 attestation, child set 누락·초과, unselected/superseded row의 signature/trust/ +semantic mismatch도 fail closed한다. 이 전체 검증이 성공한 경우에만 readiness를 연다. +query/read 또는 Java 재검증 오류, forged direct-apply row와 retained-row corruption은 +모두 dark/`NOT_QUALIFIED`, provider I/O 0인 availability/integrity incident이며 SQL structural +success나 provisioning job success를 readiness로 승격하지 않는다. + +app-bootstrap은 composition과 use case 호출만 담당하고 repository, persistence entity, JDBC, +query adapter 또는 verifier 구현을 직접 사용하지 않는다. FINAL cleanup은 transitional write +controller/command/function/grant만 삭제한다. retained provenance/operation/registry/permit/ +inventory/attestation/fence row, 위 read use case/query port/read-only adapter/verifier port와 +구현은 startup/readiness evidence를 위해 계속 보존한다. issuer는 application 운영 주체와 +분리된 external infrastructure authority이며 production artifact, container, database와 +environment에는 private key를 두지 않는다. deterministic local issuer는 test fixture에서만 +허용하고 evidence grade를 `LOCAL_TEST`로 낮추며 production provisioning/readiness는 이를 +거부한다. authorization이 동봉한 임의 key나 unreviewed environment key는 trust anchor가 +아니다. + +PRE bridge 배포에서 legacy fence를 자동 seed하지 않는다. bridge admission을 열기 전에 인증된 +human operator가 batch `INITIALIZE_LEGACY`를 호출한다. canonical binding과 post-migration fresh +provisioning의 route key +SSOT인 canonical route catalog에 PRE-only legacy alias/transport proof를 더한 compiled cutover +route catalog와 별도 reviewed runtime target-generation config를 결합한 +bounded ordered `(route revision, reviewed predecessor generation)` set과 그 set의 digest, +opaque operation token, actor digest와 bounded reason code를 받는다. fence/operation/permit을 +포함한 control table과 intent/delivery/attempt/receipt/suppression/gate/alias를 포함한 data-plane +journal이 모두 빈 경우에만 모든 route의 `ACTIVE/LEGACY@initial` fence, operation header와 route +result rows, route별 current+retiring transport-proof registry snapshot을 같은 root transaction에서 +insert한다. persisted registry의 각 route는 active admission profile이 정확히 하나이고 모든 +row는 같은 route registry digest와 initialization child FK를 가진다. 이 table은 UPDATE/DELETE가 +금지된 retained audit history이며 permit acquire는 proof class/evidence revision을 여기서 row에 +동결한다. direct SQL, application startup hook, +V7 schema migration은 이 초기화를 수행하지 않는다. 일부 route만 초기화하는 sequential +operation도 금지한다. 서로 다른 token의 동시 batch 초기화는 정확히 한 건만 이기며, 같은 token과 +같은 route set/input의 replay는 저장된 전체 결과를 반환하고 token 재사용이나 route-set mismatch는 +fail closed한다. commit failure는 mutation/성공 응답 0이고, commit-success/result-loss retry는 +operation journal의 저장 결과로 복구한다. init 전 absent/partial/extra fence key set에서는 모든 +provider I/O가 0이다. batch init 뒤 route-by-route rollout에서는 exact catalog key set 안에서 +`ACTIVE/LEGACY@predecessor`, `DRAINING/LEGACY@predecessor`, +`ACTIVE/CANONICAL@target`의 closed state만 혼재할 수 있다. legacy node는 첫 상태 route만, +canonical PRE node는 마지막 상태 route만 열고 DRAINING/owner mismatch route는 모두 닫는다. +unrelated owner/state/generation이나 catalog key mismatch는 전체 composition/readiness를 fail +closed한다. + +route revision set과 그 digest는 request authority가 아니다. proxied initializer가 retained +canonical catalog와 PRE-only cutover decorator, reviewed target config에서 server-side로 +derive하고, request는 그 exact key set에 대한 reviewed predecessor +generation map, reason과 opaque token만 제공한다. missing/extra route key 또는 caller가 주장한 +별도 digest는 거부한다. initialization 뒤 PRE artifact는 compiled decorator와 persisted +registry의 route/profile/admission-role/proof-class/evidence-revision/digest exact equality를 +startup과 readiness에서 계속 검증한다. cutover 중 registry 변경은 in-place update로 허용하지 +않는다. 새 profile/evidence revision이 필요하면 기존 sandbox/rollout을 폐기하고 별도 설계된 +registry-version 절차 없이는 진행하지 않는다. + +canonical admission은 business write + intent append와 같은 caller transaction에서 fence row를 +`SELECT ... FOR SHARE` 또는 BEGIN_DRAIN의 update lock과 충돌하는 동등한 tested primitive로 +잠그고, `ACTIVE + CANONICAL + expected generation`을 확인한다. lock은 caller physical commit/ +rollback까지 유지한다. mismatch면 business state와 intent append를 함께 rollback한다. 따라서 +이미 guard를 통과한 canonical transaction이 commit되기 전에 `BEGIN_DRAIN`이 완료되어 반대 +owner를 열 수 없다. + +legacy bridge는 provider I/O 전에 ambient transaction을 거부하는 root transaction으로 fence를 +lock하고 `ACTIVE + LEGACY + expected generation`을 확인한 뒤 opaque permit을 insert한다. physical +commit 전에는 provider를 호출하지 않는다. acquire result는 DB-time `acquired_at`, +`wire_deadline_at`, `expires_at`을 반환한다. wrapper/client는 commit acknowledgement 전부터 잰 +monotonic elapsed budget과 이 DB interval의 보수적인 minimum을 사용하고, +`wire_deadline_at` 이후에는 network I/O를 시작할 수 없다. provider call 뒤 release도 +token/version predicate를 +사용하는 별도 root transaction이다. release 실패나 process crash는 ACTIVE permit을 남기며, +switch가 이를 definite-drained로 오판하지 않는다. + +ownership switch는 sleep이나 provider I/O를 한 transaction/use case 안에 넣지 않고 다음 +audited operation으로 나눈다. + +closed transition matrix는 다음뿐이다. request는 target owner를 받지 않고 action이 server-side +result를 결정한다. + +```text +BEGIN_DRAIN: + ACTIVE/LEGACY@g -> DRAINING/LEGACY@g +TERMINALIZE_EXPIRED_PERMITS: + DRAINING/LEGACY@g -> DRAINING/LEGACY@g (fence unchanged) +COMPLETE_SWITCH: + DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1 +ABORT_DRAIN: + DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1 +``` + +`ACTIVE/CANONICAL`에서 모든 switch/terminalizer action은 mutation 0으로 실패한다. reverse +owner transition, `DRAINING/CANONICAL`과 caller-selected target owner는 존재하지 않는다. + +1. `BEGIN_DRAIN`: exact `ACTIVE/LEGACY@g`에서 fence를 `DRAINING/LEGACY@g`으로 root-commit한다. 같은 row + update lock은 in-flight canonical guard의 share lock과 legacy acquire lock 모두와 충돌한다. + 이 transaction은 trusted deployment inventory issuer가 서명한 exact environment/DB/route/ + PRE-artifact complete old-writer manifest를 server-side 검증하고, canonical node row set과 + count/set/manifest digest를 BEGIN child와 + `notification_writer_drain_node_inventory`에 동결한다. caller가 old-node digest만 보내거나, + persisted permit holder가 manifest에서 누락되거나, manifest에 unknown/duplicate + node/profile이 있으면 mutation 0이다. commit 뒤에는 새 canonical append와 legacy permit + acquire가 원자적으로 거부된다; +2. read-only application operations query로 DB time 기준 ACTIVE permit 수/최장 expiry를 bounded + poll한다. query/COMPLETE는 permit state를 변경하지 않는다. 만료 ACTIVE가 있으면 PRE 전용 + authenticated + `POST /api/admin/notifications/routes/{routeId}/writer-permits/terminalize-expired`가 + `notification:cutover-terminalize` permission의 method-security-proxied application operation을 + 호출한다. exact `DRAINING/LEGACY@drain-generation` fence와 immutable persisted registry를 + lock하고 모든 historical fence generation에서 DB-time상 만료된 ACTIVE를 bounded batch로 + 고른 뒤 exact token/row-version CAS한다. hard-bound evidence가 동결된 profile만 + `EXPIRED_PROVEN`, 현재 R0처럼 hard bound가 없는 profile은 `TIMED_OUT_UNPROVEN`으로 옮긴다. + globally unique operation token, actor/reason, affected tuple set/count/digest와 route result를 + 같은 root transaction의 operation journal에 기록하고 physical commit 뒤에만 성공 응답을 + 쓴다. idempotency lookup은 expired-set selection보다 먼저 수행한다. same token과 같은 caller + input `(route, drain generation, batch bound, actor, reason)`의 replay는 현재 ACTIVE set이 + 달라졌거나 비었어도 저장된 affected result를 반환한다. affected set은 caller input이 아니라 + derived result이고, server-canonical `request_input_digest`와 stored + `requested_batch_bound`로 token의 caller input mismatch만 실패한다. digest는 request에서 + 받지 않고 action/header/route child의 persisted canonical input fields로 재계산한다. 다른 + token은 남은 bounded + batch를 처리할 수 있다. affected digest는 terminalization token을 참조하는 immutable + post-CAS tuple + `(permit token, fence generation, profile, proof class, evidence revision, result state, + terminalized_at, row version)`의 sorted set으로 정의한다. + 이 operation은 provider I/O를 하지 않으며 시간 경과를 drained evidence로 만들지 않는다; +3. `COMPLETE_SWITCH`: exact `DRAINING/LEGACY@g`, expected generation과 route의 모든 fence + generation에 걸친 ACTIVE permit 0을 같은 root transaction에서 검증한다. 그 뒤 PRE evidence는 + 다음 discriminated union 중 정확히 하나여야 한다. + + - `PRE_QUIESCENCE_EVIDENCE`: persisted current+retiring registry row가 하나라도 + `QUIESCENCE_REQUIRED`다. exact BEGIN signed inventory header/row set과 exact 한 selected + quiescence attestation header/per-node evidence가 필수다. COMPLETE는 stored inventory와 + attestation canonical payload/signature/trust snapshot을 Java에서 다시 Ed25519 검증하고, + 모든 `TIMED_OUT_UNPROVEN` tuple, distinct permit holder, inventory node, per-node + deployment-generation tombstone와 legacy credential/egress irreversible revocation, + consumer-inventory identity/snapshot/count 0, provider-call-ledger + identity/snapshot/open-count 0의 exact equality를 lock/recompute한다. attestation이 수락되어 + immutable root transaction으로 기록된 뒤 expiry가 지나도 이 branch의 irreversible fact는 + 무효가 되지 않는다. + - `PRE_HARD_BOUND_EVIDENCE`: persisted current+retiring registry row가 모두 + `HARD_BOUND_PROVEN`이다. exact BEGIN signed inventory header/row set과 registry/evidence + revision을 Java에서 재검증하고, 모든 permit이 `RELEASED|EXPIRED_PROVEN`이며 reviewed + wire-deadline/cancellation contract를 충족해야 한다. selected attestation token과 current + drain에 귀속된 attestation row는 없어야 한다. + + 두 branch가 모두 맞거나 둘 다 아니거나, BEGIN inventory header가 없거나, QUIESCENCE branch의 + attestation이 없거나 HARD_BOUND branch에 attestation이 있으면 mutation 없이 실패한다. old-node + set 0도 signed BEGIN header 한 건이 필수다. attestation은 ACTIVE를 override하지 않으며 + `TIMED_OUT_UNPROVEN`이 0건이어도 QUIESCENCE branch에는 필요하다. authoritative registry는 + initialization과 같은 root transaction에서 동결한 + `notification_writer_transport_proof_registry`이고 PRE runtime은 compiled catalog와 exact + equality를 별도로 강제한다. token, blocking-set digest와 drain BEGIN token을 COMPLETE operation + route row에 기록한 뒤에만 owner를 `CANONICAL`, generation을 `g+1`, state를 `ACTIVE`로 CAS한다. + fence CAS와 operation append는 같은 physical commit이고 acknowledgement 뒤에만 success를 + 반환한다. `expires_at`이 지났다는 이유로 ACTIVE/TIMED_OUT_UNPROVEN row나 human gate를 + query에서 암묵적으로 제외하지 않는다; +4. 취소가 필요하면 exact `DRAINING/LEGACY@g`에서 `ABORT_DRAIN`을 audited root transaction으로 + 수행해 `ACTIVE/LEGACY@g+1`의 새 generation을 + 발급한다. 기존 generation permit을 재활성화하거나 provider call을 replay하지 않는다. + +모든 operation은 fence CAS와 같은 root transaction에서 append-only operation header/route +journal에 exact route set/action, route별 expected owner/generation과 결과 +owner/state/generation, globally unique opaque operation token, authenticated actor digest와 +bounded reason code를 기록한다. 모든 operation의 `recorded_at`, permit `terminalized_at`과 +attestation `observed_at`은 관련 global/route fence lock을 얻은 뒤 PostgreSQL +`clock_timestamp()`으로 채운다. transaction 시작 시각인 `CURRENT_TIMESTAMP`/ +`transaction_timestamp()`는 금지한다. DB-assigned `operation_sequence`와 attestation의 +`attestation_sequence`는 같은 sequence에서 batch initialization 또는 해당 route fence lock을 +획득한 뒤 발급하므로 같은 route의 committed cutover event total order다. +rollback gap과 다른 route 사이 gap은 허용하지만 duplicate/order reversal은 허용하지 않는다. +initialization은 여러 route child를, 이후 switch는 exact 한 +route child를 갖는다. fence의 optional +`last_operation_token`은 각 mutation과 같은 transaction에서 해당 header로 갱신하는 조회/검증 +포인터일 뿐 audit/idempotency SSOT가 아니다. 같은 token과 +동일 route set/input의 replay는 오래된 operation이어도 journal의 committed result를 반환하며, +token을 다른 route set/action/input에 재사용하면 fail closed한다. commit failure/commit-result loss는 추측으로 +성공 보고하지 않으며 direct SQL cutover는 이 계약을 우회하므로 금지한다. +header `route_set_digest`는 sorted exact child route set에서, `request_input_digest`는 +action-specific persisted header/child input에서 server-side로 계산한다. orphan header/child, +empty child set, header/child action 불일치와 두 digest mismatch는 runtime과 V8 모두 거부한다. +두 digest는 domain-separated, versioned `writer-operation-route-set-v1` / +`writer-operation-input-v1` length-prefixed SHA-256 canonicalization을 사용하고 raw PII/secret을 +입력에 넣지 않는다. +`TERMINALIZE_EXPIRED_PERMITS`는 fence를 mutate하지 않으므로 `last_operation_token`을 갱신하지 +않고, affected permit의 `terminalization_operation_token`만 journal header를 참조한다. +terminal permit은 composite +`(terminalization_operation_token, route_revision)` FK로 exact operation child를 참조한다. +그 child는 action/route/drain generation과 unchanged `DRAINING/LEGACY` result를 기록하고, +affected count/digest는 그 token을 참조하는 post-CAS permit tuple set과 exact equality다. + +permit expiry는 live provider call이 끝났다는 증거가 아니다. legacy transport가 permit +root-commit에서 DB-time으로 동결한 absolute `wire_deadline_at`, +`wire_deadline_at + finalize margin <= expires_at`, deadline 뒤 network-start 거부, +connection close/cancellation이 wire deadline까지 확정되는 client contract와 process +pause/resume을 integration test로 증명한 경우에만 `EXPIRED_PROVEN`을 drained 판단에 포함한다. +특히 acquire commit 직후 process가 permit expiry 이후까지 pause되었다가 resume하면 provider +call은 0이어야 하고, deadline 직전 resume한 call도 그 absolute deadline까지 종료되어야 한다. +이 증거가 하나라도 없으면 profile은 `QUIESCENCE_REQUIRED`다. 현재 legacy +seam처럼 그 hard bound가 없으면 authenticated operator가 production consumer 0, old-node 완전 +quiesce와 provider-call ledger 0의 signed durable evidence를 append-only attestation으로 +root-commit해야 한다. `COMPLETE_SWITCH` command가 exact attestation token을 제공하지 않거나, +route/generation/profile-set/blocking-permit-set/signed fact가 맞지 않으면 DB CAS 자체가 +실패한다. permit release/timeout terminal transition으로 snapshot이 달라지면 새 attestation이 +필요하다. evidence acceptance TTL만 지나거나 +runbook checkbox만 확인해 호출이 끝났다고 추정하지 않는다. +permit state와 동결 proof class는 교차 불변식이다. +`EXPIRED_PROVEN`은 `HARD_BOUND_PROVEN`에만, +`TIMED_OUT_UNPROVEN`은 `QUIESCENCE_REQUIRED`에만 허용한다. `ACTIVE|RELEASED`는 양쪽 proof +class에 허용되지만 COMPLETE/V8은 persisted registry와 tuple equality를 다시 검증한다. + +legacy/operator code가 제거된 final artifact는 fresh database에서도 canonical fence를 얻어야 +하지만 “notification table이 비었다”는 조건만으로 fresh를 추론하지 않는다. cleanup 전용 +additive V8은 database를 canonical-ready로 직접 seed하지 않고 다음 structural classification만 +수행한다. + +- notification control/data-plane, fence, journal과 provenance가 완전히 비면 exact 한 + `AWAITING_SIGNED_FRESH_PROVISIONING` discriminator를 남긴다. 이 state에서는 fence와 + `INITIALIZE_CANONICAL_FRESH` history가 0이다; +- complete PRE upgrade history와 reviewed canonical route-revision key set의 + `ACTIVE/CANONICAL@g_final` fence가 있으면 history를 검증·보존하고 exact 한 + `UPGRADE_VALIDATED` discriminator와 `validated_upgrade_history_digest`를 남긴다. + +fence가 없는데 notification row가 하나라도 있거나 partial/extra fence/history, 선행 provenance, +두 classification의 혼합 또는 discriminator mismatch가 있으면 V8은 실패한다. empty store를 +upgrade나 fresh canonical state로 추론하지 않는다. V8 뒤 `notificationFreshProvisioning`이 +issuer가 먼저 commit한 irreversible no-legacy-authority fence를 포함한 signed DB-birth +authorization을 위 snapshot/read-lock -> Java verifier -> apply protocol로 검증하고 한 physical +transaction에서 provenance, `INITIALIZE_CANONICAL_FRESH` 전체 route history와 initial canonical +fence를 만든 경우에만 `FRESH_PROVISIONED`가 된다. + +FINAL startup/readiness evidence는 다음 closed union 중 정확히 하나다. + +- `FINAL_FRESH`: discriminator가 `FRESH_PROVISIONED`이고 exact 한 signed fresh provenance, + provenance token을 참조하는 exact 한 `INITIALIZE_CANONICAL_FRESH` header/전체 route child, + reviewed initial `ACTIVE/CANONICAL` fence set이 있다. provenance의 DB resource/birth + certificate, zero inventories/provider ledger와 committed irreversible + no-legacy-authority-fence field가 canonical signed payload와 exact equality여야 한다. + upgrade-history discriminator와 `INITIALIZE_LEGACY` history는 없어야 한다; +- `FINAL_UPGRADE`: discriminator가 `UPGRADE_VALIDATED`이고 V8이 동결한 exact + `validated_upgrade_history_digest`, audited `INITIALIZE_LEGACY`에서 route별 + `ACTIVE/CANONICAL@g_final`로 끝난 complete history와 fence set이 있다. fresh provenance와 + `INITIALIZE_CANONICAL_FRESH` history는 없어야 한다. + +둘 다 맞거나 둘 다 아니거나 반대 branch의 marker/history가 섞이면 startup/readiness는 dark다. +`AWAITING_SIGNED_FRESH_PROVISIONING`도 정상 migration completion state일 수 있지만 runtime +canonical admission/claim/provider I/O는 0이고 provisioning 전에는 ready가 아니다. 위 +application read use case -> retained-evidence query port -> persistence read-only adapter -> +verifier port seam이 한 bounded consistent snapshot에서 이 closed union을 판정한다. FRESH +branch는 discriminator/provenance/init child/fence를 모두 읽고 provisioning write와 이후 모든 +startup에서 retained provenance의 payload/signature/SPKI/trust snapshot, DB birth/fence fact와 +semantic exact equality를 Java로 재검증한다. UPGRADE branch는 discriminator와 full +operation/registry/permit/inventory row, 모든 selected·superseded·unselected attestation +header/child 및 fence를 읽고 retained BEGIN inventory와 모든 quiescence attestation의 +payload/signature/SPKI/trust snapshot과 semantic exact equality를 Java로 재검증한다. V8과 SQL +constraint는 canonical +bytes/digest/count/FK/time shape만 검증하며 cryptographic validity의 authority가 아니다. + +upgrade database에서는 reviewed route-revision key set exact equality를 요구하되 generation은 +route별 audited `g_final`일 수 있다. 모든 fence의 `ACTIVE/CANONICAL`, 각 fence와 최신 +`last_operation_token -> COMPLETE_SWITCH` route result의 owner/state/generation 일치를 +검증한다. upgrade의 transport-proof authority는 삭제될 PRE catalog나 attestation +self-assertion이 아니라 retained immutable +`notification_writer_transport_proof_registry`다. registry route key set은 fence/canonical key +set과 exact equality이고 route마다 ACTIVE profile이 정확히 하나여야 한다. 모든 registry row는 +같은 route digest와 initialization-operation child FK를 가져야 하며, 모든 permit의 frozen +profile/proof-class/evidence-revision, attestation registry digest와 COMPLETE child의 +proof-requirement/registry digest가 이 snapshot과 exact equality여야 한다. permit이 0인 +`QUIESCENCE_REQUIRED` route도 이 retained row 때문에 누락되지 않는다. ACTIVE permit은 항상 +실패하고 proof-class/state 교차 불변식 위반도 실패한다. + +각 route의 마지막 COMPLETE는 PRE evidence closed union을 replay한다. + +- `PRE_QUIESCENCE_EVIDENCE`는 signed BEGIN inventory header와 exact inventory row set, selected + attestation의 exact BEGIN FK/profile registry/blocking permit set/distinct holder set/old-node + set, per-node deployment-generation tombstone와 legacy credential/egress irreversible + revocation, consumer-inventory identity/snapshot/count 0, provider-ledger + identity/snapshot/open-count 0, COMPLETE child의 selected token/blocking-set digest를 요구한다. + `BEGIN.operation_sequence < attestation.attestation_sequence < + COMPLETE.operation_sequence`여야 한다. + `issued_at - allowed_clock_skew <= server_verified_at + <= expires_at - acceptance_margin`은 attestation을 처음 기록한 acceptance가 유효했음을 + 보존한다. cleanup 현재 시각이 + `expires_at` 뒤여도 immutable tombstone/revocation과 zero-ledger snapshot은 유효하다. +- `PRE_HARD_BOUND_EVIDENCE`는 signed BEGIN inventory header와 exact inventory row set, 모든 + current+retiring registry row의 `HARD_BOUND_PROVEN`, 모든 permit의 + `RELEASED|EXPIRED_PROVEN`과 reviewed deadline/cancellation evidence revision을 요구한다. + 마지막 drain-BEGIN에 selected attestation이나 attestation row가 있으면 실패한다. + +두 PRE branch가 모두 맞거나 둘 다 아니면 실패하고 old-node row가 0개인 BEGIN도 signed header +한 건을 요구한다. Java startup verifier는 stored canonical payload/signature/trust snapshot과 +semantic header/child row exact equality를 재검증한다. unknown issuer/key, trust snapshot +mismatch, payload/signature 불일치, reversible fence 또는 ledger identity/snapshot mismatch는 +upgrade를 중단한다. + +`EXPIRED_PROVEN|TIMED_OUT_UNPROVEN` permit은 exact route의 +`TERMINALIZE_EXPIRED_PERMITS` child를 composite FK로 참조해야 한다. V8은 action/route/drain +BEGIN FK/generation, unchanged DRAINING/LEGACY result, +`expires_at <= terminalized_at`, +`BEGIN.operation_sequence < terminalizer.operation_sequence < +first_closing_ABORT_or_COMPLETE.operation_sequence`와 child affected count/digest를 그 token을 +참조하는 immutable post-CAS permit tuple set에서 재계산한다. `recorded_at`/`terminalized_at`은 +post-lock `clock_timestamp()` shape와 expiry sanity를 보조 검증할 뿐 causal SSOT가 아니다. +wrong action/route/set, orphan token, expiry 전 또는 drain close 뒤 terminalization은 실패한다. +fence, permit, signed inventory/attestation header와 child row, per-node quiescence fence evidence, +fresh provenance, finalization discriminator, proof registry와 operation history를 모두 +byte-for-byte 보존한다. + +upgrade에서는 V8이 operation과 attestation이 공유하는 DB sequence를 route별로 replay한다. 허용 +operation history는 +`INITIALIZE_LEGACY -> (BEGIN -> TERMINALIZE* -> ABORT)* -> BEGIN -> TERMINALIZE* -> COMPLETE`이고 +COMPLETE는 해당 route의 마지막 mutation이어야 한다. 각 transition의 expected/result +owner/state/generation과 drain-BEGIN FK가 closed matrix와 일치해야 하며 terminalizer는 +DRAINING self-transition일 뿐이다. CANONICAL 뒤 BEGIN/TERMINALIZE/ABORT/두 번째 COMPLETE, +sequence duplicate/collision/reversal, missing predecessor와 journal replay 결과/fence/ +latest-mutation-pointer 불일치는 모두 실패한다. replay 전에 모든 header를 child와 양방향 +대조한다. 두 INITIALIZE header는 reviewed canonical route set과 정확히 같은 child set을, 모든 +non-init header는 exact 한 route child를 가져야 한다. header `route_set_digest`는 sorted child +route set과, `request_input_digest`는 action-specific persisted input과 재계산 equality여야 한다. +orphan/extra/empty header 또는 child는 모두 실패한다. + +immutable attestation은 permit row-version 변화 뒤 재발급되거나 ABORT로 선택되지 않을 수 있다. +same drain-BEGIN FK를 가지며 +`BEGIN.operation_sequence < attestation.attestation_sequence < +first_closing_ABORT_or_COMPLETE.operation_sequence`이고 signed payload/header/child 구조가 유효한 +superseded/unselected row만 audit history로 보존·허용한다. latest COMPLETE의 QUIESCENCE branch가 +선택한 token만 exact set을 full 검증하고 HARD_BOUND branch의 latest drain에는 attestation을 +금지한다. BEGIN 없는 forged row, ABORT/COMPLETE 뒤의 sequence, missing selected row와 selected +token/digest mismatch는 실패한다. LEGACY/DRAINING, ACTIVE permit, missing/extra route, +latest-operation mismatch, missing/mismatched signed inventory/attestation, BEGIN-less forged +history, discriminator branch mismatch 또는 missing-fence-with-any-nonempty-journal은 fail +closed한다. app-bootstrap startup도 compiled canonical route set과 persisted fence set/expected +generation exact equality를 검증한다. + +### 16.8 database constraint와 index + +다음은 migration과 PostgreSQL integration test가 강제할 최소 invariant다. + +- 모든 table은 opaque PK를 갖고 child row는 parent에 FK를 둔다. intent hard delete는 live + delivery/attempt/receipt가 있으면 금지하고 retention worker가 명시된 purge order를 따른다; +- intent source dedupe alias는 + `(tenant_scope, purpose, hmac_key_version, digest)` unique이며 stable semantic owner 하나만 + 가리킨다; +- `notification_delivery_leg(notification_id, target_ordinal)` unique; +- `notification_attempt(delivery_id, attempt_ordinal)` unique; +- `notification_attempt(attempt_execution_token)` unique와 exact provider-result fact 최대 1개; +- delivery당 open attempt 최대 1개 partial unique; +- `(notification_id, strategy_group)`당 `QUEUED/CLAIMED/ATTEMPT_RESERVED/WIRE_AUTHORIZED/ + RETRY_WAIT/PARKED_BINDING/RECONCILE_WAIT/RECONCILING` fallback leg 최대 1개 partial unique; +- admission gate PK/CAS와 leg park transition은 같은 finalize transaction에서 갱신한다; +- route writer fence는 route당 한 row이고 `(route_revision, generation, owner, state, + row_version)` predicate로 CAS한다. `BEGIN_DRAIN` 뒤 새 legacy permit은 0이어야 한다; +- writer operation token은 header에서 globally unique고 route child PK는 + `(operation_token, route_revision)`이다. batch 초기화와 모든 single-route ownership CAS는 + append-only header/route rows를 같은 root transaction에 기록하고, same-token/same-input + replay는 저장된 전체 committed result를 반환하며 token/route-set/input mismatch는 mutation + 없이 거부한다. operation과 attestation은 같은 DB sequence를 관련 fence/global lock 뒤 + 발급받고 두 table 사이 collision까지 V8이 거부해 route별 committed causal total order를 + 제공한다. 모든 cutover timestamp는 lock 뒤 `clock_timestamp()`으로 기록하고 + `CURRENT_TIMESTAMP`/`transaction_timestamp()`를 causal authority로 사용하지 않는다; +- BEGIN child의 reviewed complete node count/set/manifest digest는 같은 transaction의 exact 한 + immutable signed inventory header와 drain-node inventory row set에 exact equality다. node 0도 + header 한 건과 canonical empty-set digest가 필수다. inventory와 attestation header는 bounded + canonical payload/signature/issuer identity/key/bounded canonical public-key SPKI bytes/digest/ + canonical trust-snapshot payload/digest, signed acceptance-window profile/skew/margin과 + issued/expiry/server-verified metadata, environment/DB/artifact, consumer-inventory identity/snapshot과 + provider-ledger identity/snapshot을 보존한다. SQL은 non-null/length/digest/count/FK와 + nonnegative reviewed skew/margin, + `issued_at - allowed_clock_skew <= server_verified_at + <= expires_at - acceptance_margin`을 강제하고 Java write/startup verifier가 + Ed25519와 trust catalog를 재검증한다. permit의 distinct holder set이 inventory의 subset이 + 아니거나 attestation manifest node set이 inventory와 exact equality가 아니면 실패한다; +- transport-proof registry는 batch initialization의 operation child와 같은 root transaction에서 + exact route/current+retiring profile set으로만 insert한다. route마다 ACTIVE admission profile은 + 정확히 하나이고 registry digest는 모든 route child row에서 일치해야 한다. UPDATE/DELETE는 DB + constraint/trigger와 adapter surface 모두에서 금지한다. permit의 frozen profile/proof + class/evidence revision, attestation와 COMPLETE child의 registry digest는 이 retained snapshot과 + exact equality여야 한다; +- writer permit token은 globally unique다. active permit lookup은 + `(route_revision, fence_generation, owner, state, expires_at)` index를 사용하고 release/expiry는 + exact token + row version으로 한 번만 전이한다. hard-bound가 없으면 timeout은 + `TIMED_OUT_UNPROVEN`이고 자동 drained terminal이 아니다. + `EXPIRED_PROVEN => HARD_BOUND_PROVEN`, + `TIMED_OUT_UNPROVEN => QUIESCENCE_REQUIRED`를 DB CHECK로 강제하며 반대 조합을 insert/update할 + 수 없다. timeout terminal state는 `(terminalization_operation_token, route_revision)` composite + FK, non-null terminalized_at과 `expires_at <= terminalized_at`을 요구하고 ACTIVE/RELEASED는 + terminalization fields를 금지한다. `COMPLETE_SWITCH`와 operations + snapshot은 같은 locked fence route의 LEGACY ACTIVE/TIMED_OUT_UNPROVEN permit을 모든 + generation에 걸쳐 본다. ACTIVE는 항상 0이어야 하고, attestation은 exact + TIMED_OUT_UNPROVEN set을 덮는다. unproven transport는 그 set이 0이어도 exact signed + quiescence attestation이 필수다; +- quiescence attestation token은 globally unique하고 immutable하다. exact route/drain + generation/BEGIN FK, bounded transport-profile set digest, blocking permit count/set digest, + distinct holder set, frozen old-node set, exact per-node tombstone/revocation row-set digest, + signed consumer/provider-ledger zero-fact evidence와 retained canonical signature header를 가지며 + `COMPLETE_SWITCH` operation child가 token과 same blocking-set digest를 FK/constraint로 + 참조한다. acceptance window 밖의 새 evidence, mismatched/reused evidence, caller-authored + inventory digest 또는 multi-profile/node set 일부만 덮는 evidence는 mutation 없이 거부한다. + root-committed attestation은 per-node irreversible tombstone/revocation과 provider-ledger zero + snapshot의 durable proof이므로 이후 wall clock expiry로 무효화하지 않는다; +- COMPLETE는 exact 한 PRE evidence branch만 허용한다. + `PRE_QUIESCENCE_EVIDENCE`는 BEGIN inventory+selected signed attestation+per-node irreversible + evidence+ACTIVE permit 0이고, `PRE_HARD_BOUND_EVIDENCE`는 BEGIN inventory+all-hard-bound + registry+safe terminal permit+ACTIVE permit 0이며 current drain attestation은 0이다. SQL은 + structural exact-set/FK/CHECK를 강제하고 Java transition verifier가 retained Ed25519 payload를 + 재검증한다. correctness는 constraint execution timing에 의존하지 않는다; +- V8은 complete upgrade를 `UPGRADE_VALIDATED`로 보존하거나 완전히 empty store를 + `AWAITING_SIGNED_FRESH_PROVISIONING`으로 남길 뿐 canonical fence를 seed하지 않는다. any + nonempty missing-fence/partial-history store는 실패한다. 별도 `notificationFreshProvisioning`만 + independent issuer가 먼저 commit한 irreversible no-legacy-authority fence를 포함한 signed + DB-birth authorization을 Java로 검증한다. 같은 provisioner transaction의 exact + snapshot/read-lock과 apply function 사이에서 검증하며 apply의 fresh DB time이 window 안일 + 때만 provenance, exact 한 `INITIALIZE_CANONICAL_FRESH` batch, 모든 reviewed canonical fence와 + `FRESH_PROVISIONED` discriminator를 만든다. FINAL은 `FINAL_FRESH(provenance)`와 + `FINAL_UPGRADE(validated history discriminator)`의 closed union이다; +- database role topology는 정확히 `notification_migrator`, `notification_runtime`, + `notification_provisioner` 세 개다. `notification_migrator`는 notification schema object와 + 모든 `SECURITY DEFINER` function을 소유하고 Flyway에서만 사용하는 dedicated LOGIN + migration-only principal이며 일반 application/provisioning datasource가 아니다. + `notification_runtime`은 non-owner다. PRE에서는 exact initializer/switch/permit/ + terminalizer/attestation function `EXECUTE`를 method-security로 보호된 application use case + path를 통해서만 사용하고, retained evidence bounded projection `SELECT`와 canonical fence + read/lock에 필요한 최소 권한만 가진다. FINAL migration은 runtime의 transitional function + `EXECUTE`, retained cutover audit/control `INSERT|UPDATE|DELETE`와 cutover sequence `USAGE`를 + 명시적으로 revoke한다. PRE와 FINAL 모두 normal runtime의 active-release + intent/delivery/attempt/receipt 등 operational journal에 필요한 exact DML/SELECT와 그 전용 + sequence 권한은 별도 least-privilege grant로 유지한다; +- `notification_provisioner`는 + `notification_fresh_provisioning_snapshot_and_lock`과 + `notification_fresh_provisioning_apply` 두 function의 `EXECUTE`만 가진다. retained + audit/control/operational journal generic `SELECT|INSERT|UPDATE|DELETE`, 모든 sequence + `USAGE`, DDL, transitional function과 그 밖의 function `EXECUTE`, role membership은 0이다. + normal runtime은 이 두 fresh function의 `EXECUTE`를 갖지 않는다; +- 모든 `SECURITY DEFINER` function은 migration-only `notification_migrator`가 소유하고 + `SET search_path = pg_catalog`, fully-qualified object name, bounded typed input/output, + dynamic SQL 0을 강제하며 `PUBLIC EXECUTE`를 revoke한다. 특히 fresh 두 function은 같은 + physical provisioner transaction/connection의 lock protocol을 강제하고 apply가 state, + identity, snapshot/payload semantic digest와 fresh `clock_timestamp()` acceptance window를 + 재검산한다. SQL은 cryptographic validity를 주장하지 않으며 Ed25519/trust 검증은 두 function + 사이의 Java verifier port가 담당한다; +- provider receipt는 + `(provider_binding_revision, event_digest_key_version, provider_event_id_digest)`와 + `(provider_binding_revision, semantic_digest_key_version, semantic_event_fingerprint)`로 + outer retry와 의미상 중복을 각각 차단한다; +- provider message reference/correlation lookup은 binding revision과 HMAC key version까지 scope에 + 포함하고 하나의 open delivery/attempt와만 매칭한다; +- eligible scan index는 최소 `(state, next_action_at, admission_class, notification_id)`, + stale lease scan은 `(state, claim_lease_until)`, orphan attach는 provider binding과 + correlation/message-reference digest를 선두로 둔다; +- constraint conflict를 catch-and-ignore로 처리하지 않고 typed idempotent/conflict 결과로 + mapping한다. + +### 16.9 claim protocol + +claim query는 eligible state, `next_action_at`, expiry, 모든 admission gate ACTIVE와 bounded +batch를 사용하고 PostgreSQL +`FOR UPDATE SKIP LOCKED` 또는 동등한 tested primitive를 사용할 수 있다. + +claim/finalize update의 필수 predicate: + +```text +WHERE delivery_id = ? + AND claim_owner_token = ? + AND state = expected_state + AND row_version = expected_version +``` + +영향 row가 정확히 1이 아니면 stale owner/conflict다. stale worker는 provider result를 새 owner의 +state 위에 덮지 못한다. 다만 정확한 provider response는 claim owner와 독립된 immutable +`attempt_execution_token`으로 해당 open attempt에 terminal-once append할 수 있고, projection +merge만 현재 owner/version CAS를 사용한다. + +claim transaction 안에서 provider call/render-heavy work를 하지 않는다. +`WIRE_AUTHORIZED` transaction은 gate를 `(scope_type, scope_revision)` canonical order로 +lock/read해 deadlock을 피하고, authorized generation set을 attempt fact에 남긴다. + +### 16.10 lease expiry와 reaper + +- `CLAIMED`와 `ATTEMPT_RESERVED`는 `WIRE_AUTHORIZED` evidence가 없으므로 lease 만료 뒤 안전하게 + requeue할 수 있다; +- `WIRE_AUTHORIZED` 이후 owner를 잃으면 attempt deadline과 transport/finalize grace가 끝나기 + 전에 retry/fallback을 활성화하지 않는다; +- grace 뒤 정확한 result fact가 없으면 retry queue가 아니라 provider card에 따라 + `RECONCILE_WAIT` 또는 `TERMINAL_INDETERMINATE`로 이동한다; +- clock skew와 DB clock/application clock ownership을 명시한다; +- reaper는 provider side effect가 없었다고 추론하지 않는다; +- expired intent도 이미 maybe-sent인 attempt를 not-sent/cancelled로 낮추지 않는다. + +### 16.11 transaction failure + +provider accepted 뒤 finalize transaction이 실패할 수 있다. 다음을 보장하지 못한다. + +```text +external provider side effect + +local notification_delivery_leg projection update +``` + +따라서 pre-send correlation/native operation key가 있으면 provider contract대로 사용하고, +response loss/finalize failure는 지원되는 lookup mode로 reconciliation한다. post-response +message reference가 없는데 있다고 가정하지 않는다. reconciliation이 없는 provider는 +`TERMINAL_INDETERMINATE`와 수동 runbook을 갖는다. + +### 16.12 broker wake-up + +향후 throughput/latency 때문에 broker를 쓰더라도 payload에는 encrypted notification body를 +복제하지 않고 opaque `NotificationIntentId` 또는 delivery wake-up key만 싣는다. + +broker message는 hint다. consumer는 DB state/claim을 다시 확인한다. duplicate/lost wake-up이 +정확성에 영향을 주지 않도록 periodic DB scan을 유지한다. + +## 17. provider capability matrix + +초기 provider 후보의 목표 위치는 다음과 같다. + +| Provider | Channel | 초기 위치 | native idempotency | response reference | recipient feedback | R2 판정 | +| --- | --- | --- | --- | --- | --- | --- | +| `slack-web-api` | Slack | reference | 의존할 문서 계약 없음 | `channel`, `ts` | 사용자 delivery/read 없음 | sandbox evidence 필요 | +| `slack-webhook` | Slack | legacy fixed target | 없음 | 없음 | 없음 | R0/R1 compatibility | +| `aws-ses-v2` | Email | reference | `SendEmail` client token 없음 | `MessageId` | delivery/delay/bounce/complaint 등 | sandbox/feedback evidence 필요 | +| `google-email` | Email | legacy ambiguous seam | 정의 안 됨 | 정의 안 됨 | 정의 안 됨 | R0 only | +| `gmail-api` | Email | optional future | send native idempotency 계약 없음 | Gmail message resource | generic recipient delivery feedback 아님 | 별도 card | +| `smtp` | Email | optional future transport | protocol 전체의 generic idempotency 없음 | server-dependent | DSN/feedback topology별 상이 | 별도 card | + +한 provider의 submission API와 callback/reconciliation capability를 별도 provider인 것처럼 +오해하지 않는다. capability card는 send path, feedback transport, account/region/workspace와 +credential mode를 함께 고정한다. + +### 17.1 최소 R2 candidate card의 exact set + +초기 implementation/qualification 범위는 다음 세 card뿐이다. 이 목록은 target이며 required +evidence가 쌓이기 전에는 R2라고 부르지 않는다. + +| Card ID | 정확한 보장 | +| --- | --- | +| `slack-web-api-inline-single-local-v1` | application-policy inline, SINGLE, local Block Kit/text renderer, `chat.postMessage`, `(channel,ts)` conversation post, response-loss terminal unknown, receipt/reconcile 없음 | +| `slack-web-api-durable-single-local-v1` | same-DB append, one provider leg, local renderer, `chat.postMessage`, response-loss terminal unknown, receipt/reconcile 없음 | +| `aws-ses-v2-durable-single-local-sns-v1` | same-DB append, one recipient/leg/call, local-rendered text/HTML, SES v2 `SendEmail`, pre-send EmailTag correlation, SNS HTTPS feedback와 직교 projection | + +각 deployed card instance는 §7.1의 모든 축, exact provider binding revision, Slack +workspace/channel class 또는 AWS account/region/configuration set/topic, resolved credential source, +evidence manifest digest와 maturity를 채운다. derived card는 축 하나라도 바뀌면 새 ID/revision과 +독립 evidence를 요구한다. + +`FAN_OUT_ALL`과 `ORDERED_FALLBACK` kernel은 R1 contract 대상으로 구현할 수 있으나, 정확한 +provider chain/card ID와 concurrency/fault evidence를 승인하기 전에는 초기 provider R2 set에 +포함하지 않는다. legacy `slack-webhook`, `google-email`, future Gmail/SMTP도 이 세 card의 +evidence를 상속하지 않는다. + +## 18. Slack reference provider + +### 18.1 선택 + +초기 R2 reference는 Slack Web API +[`chat.postMessage`](https://docs.slack.dev/reference/methods/chat.postMessage/)다. + +선택 이유: + +- route binding이 고정한 channel ID를 요청마다 정확히 선택할 수 있다; +- 성공 응답에 `channel`과 message `ts`가 있다; +- thread/update/delete와 future reconciliation에 사용할 provider reference를 얻는다; +- incoming webhook보다 destination/capability가 명시적이다. + +provider ID는 capability 차이를 드러내는 `slack-web-api`를 사용한다. 기존 +`slack-webhook`을 같은 ID 뒤의 credential mode로 숨기지 않는다. + +### 18.2 destination과 권한 + +application은 Slack channel ID를 전달하지 않는다. + +```text +NotificationRouteId + -> compiled target + -> workspaceBindingId + -> channelId secret/config reference + -> provider credential reference +``` + +bot token은 least-privilege `chat:write`를 기준으로 하고 public/private channel 접근과 membership을 +startup/readiness card에서 검증한다. 모든 public channel에 쓰는 추가 scope를 편의상 기본 +요구하지 않는다. + +credential은 workload secret provider reference로 주입하고 plain application YAML, test fixture, +log에 넣지 않는다. resolution ownership은 §24.2의 bootstrap bridge/adapter-owned material +factory를 따른다. token rotation을 사용하는 profile은 old/new token generation과 in-flight +attempt의 binding revision을 정의한다. + +### 18.3 payload + +- local typed renderer가 `text`와 bounded Block Kit payload를 만든다; +- accessibility fallback용 top-level `text` 정책을 template descriptor에 둔다; +- block/element/text/overall byte 상한은 Slack documented limit보다 보수적으로 설정한다; +- arbitrary channel/user mention과 external URL은 allowlisted value type만 허용한다; +- correlation metadata를 쓰더라도 secret/PII를 넣지 않는다; +- unfurl은 route policy에서 명시적으로 disable/allow한다; +- provider raw JSON을 application parameter로 받지 않는다. + +### 18.4 rate limit과 retry + +Slack은 message posting에 channel별 대략 초당 1건 기준과 HTTP `429`의 `Retry-After` 처리를 +문서화한다. 정확한 burst 크기를 capacity 상수로 사용하지 않는다. + +dispatcher는 `(workspaceBinding, channelId)`별 bounded rate bucket/admission을 둔다. + +- local admission wait도 intent deadline 안에 포함한다; +- `429`는 response가 해당 request를 수락하지 않았다는 exact card evidence가 있을 때 + retryable rejection으로 분류한다; +- `Retry-After`는 local maximum과 expiry로 cap한다; +- timeout/5xx/response parse failure는 provider가 side effect를 만들었을 수 있으므로 + 기본 `INDETERMINATE`다; +- retry worker/thread를 target마다 무한 생성하지 않는다. + +공식 기준: +[Slack Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/). + +### 18.5 success와 receipt 의미 + +`chat.postMessage` 성공 응답의 `(workspace, channel, ts)`를 encrypted/opaque provider message +reference로 저장한다. + +```text +PROVIDER_ACCEPTED + + (workspace, channel, ts) + -> POSTED_TO_CONVERSATION +``` + +이는 Slack conversation에 message가 생성되었다는 의미다. 특정 사용자의 desktop/mobile push +도착 또는 읽음을 증명하지 않는다. + +`conversations.history` 또는 event를 이용한 확인은 conversation presence reconciliation일 뿐 +user delivery receipt가 아니다. response를 잃어 `ts`가 없는 unknown attempt에서 history +absence만으로 definite-not-posted를 증명하지 않는다. + +공식 기준: + +- [Web API response contract](https://docs.slack.dev/apis/web-api/) +- [conversations.history](https://docs.slack.dev/reference/methods/conversations.history/) +- [message event](https://docs.slack.dev/reference/events/message/) + +### 18.6 idempotency와 unknown outcome + +현재 `chat.postMessage`의 normative method contract에는 운영상 의존할 수 있는 request +idempotency key와 dedupe retention semantics가 없다. error reference의 특정 field 이름을 +idempotency guarantee로 승격하지 않는다. + +따라서 response-loss attempt는 blind retry하지 않는다. duplicate-tolerant route가 아닌 한 +terminal/manual reconciliation 또는 provider card가 검증한 별도 reconciliation로 이동한다. + +### 18.7 incoming webhook compatibility + +Incoming Webhook은 다음 exact capability로만 등록한다. + +```text +FIXED_DESTINATION +NO_PROVIDER_MESSAGE_REFERENCE +NO_DOCUMENTED_IDEMPOTENCY +NO_USER_RECEIPT +NO_UPDATE_DELETE_BY_WEBHOOK +``` + +webhook URL 자체가 secret이며 고정 destination에 결합된다. 성공은 일반적으로 HTTP 200과 +`ok` text지만 `ts`를 반환하지 않는다. dynamic channel, durable reconciliation 또는 +receipt-required route에 사용하지 않는다. + +공식 기준: +[Sending messages using incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/). + +### 18.8 Slack qualification + +R2 evidence lane은 production token이 아니라 별도 +[Slack developer sandbox/test workspace](https://docs.slack.dev/tools/developer-sandboxes/)와 +격리 channel을 사용한다. + +필수 evidence: + +- valid post와 returned `(channel, ts)`; +- invalid auth/channel/scope classification; +- 429와 `Retry-After`; +- deadline/connection loss fault injection의 indeterminate 분류; +- message size/block/escaping contract; +- credential rotation; +- per-channel concurrency/admission; +- no PII/secret telemetry; +- optional history/event presence 확인의 정확한 한계. + +Slack은 provider-side dry-run/emulator를 baseline으로 제공한다고 가정하지 않는다. + +## 19. Email reference provider + +### 19.1 선택 + +초기 R2 reference는 Amazon SES v2 +[`SendEmail`](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html/)이다. + +선택 이유: + +- transactional email submission API와 account sending 상태/quota가 명시되어 있다; +- message ID와 event destination을 통한 delivery/bounce/complaint lifecycle을 구성할 수 있다; +- AWS SDK v2, workload IAM과 region/account profile을 명확히 고정할 수 있다; +- Gmail mailbox-specific OAuth/quotas를 generic baseline에 결합하지 않는다. + +provider ID는 `aws-ses-v2`다. 기존 `google-email` provider를 내부에서 SES로 바꾸지 않는다. + +### 19.2 한 recipient 한 provider call + +최소 R2는 SES `SendEmail` 한 호출에 intent의 logical recipient 정확히 한 명만 보낸다. + +이유: + +- recipient별 outcome, bounce/suppression, attempt identity를 정확히 연결한다; +- multi-destination partial semantics를 피한다; +- provider message ID를 하나의 delivery와 매핑한다; +- fan-out budget과 privacy boundary가 명확해진다. + +대량 personalized/bulk API는 별도 capability card와 partial result/state model이 필요하다. + +### 19.3 identity, sender와 credential + +route binding은 다음을 고정한다. + +```text +awsAccountBinding +region +verifiedFromIdentity +configurationSet +replyTo policy +feedback event destination +credential mode +``` + +application은 from address, region, configuration set 또는 IAM credential을 선택하지 않는다. + +credential baseline은 AWS SDK v2 +[default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html/) +을 무조건 허용하는 것이 아니라 deployed card가 +`WEB_IDENTITY`, `CONTAINER` 또는 `INSTANCE_PROFILE` 중 resolved source를 하나 고정하는 workload +role profile이다. default chain을 구현에 사용하더라도 readiness가 실제 선택된 source를 exact +card와 비교해야 한다. production에서 `SYSTEM_PROPERTY`/`ENVIRONMENT_STATIC` credential이 +선택되면 startup/readiness를 실패시킨다. static access key literal은 settings에 넣지 않는다. +IAM은 route가 필요한 verified identity/configuration set/send operation으로 최소화한다. +client construction/refresh는 §24.2의 bootstrap bridge와 adapter-owned credential factory를 +따르며 application/settings에 resolved credential 값을 전달하지 않는다. + +공식 기준: +[Controlling access to Amazon SES](https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html). + +### 19.4 content + +- local renderer가 UTF-8 text와 HTML part를 만든다; +- subject/header control character를 거부한다; +- from/reply-to/return-path는 route policy가 고정한다; +- provider-stored SES template는 별도 `SES_STORED_TEMPLATE` card로만 지원한다; +- open/click tracking은 privacy/security/URL mutation을 검토한 route에서만 opt-in한다; +- attachment/raw MIME는 최소 R2에서 제외한다; +- provider hard limit보다 낮은 local encoded-byte limit을 둔다. + +### 19.5 send response의 의미 + +SES `SendEmail` 응답의 `MessageId`는 요청이 accepted되었다는 evidence다. AWS 문서도 accepted +message가 이후 실제로 전송되지 않을 수 있음을 명시한다. + +```text +SendEmail MessageId + -> PROVIDER_ACCEPTED + != DELIVERED_TO_RECIPIENT_MTA + != INBOX_DELIVERED + != READ +``` + +공식 기준: + +- [SES email sending process](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html) +- [SendEmail API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) + +### 19.6 idempotency, SDK retry와 indeterminate + +SES v2 `SendEmail` request에는 notification coordinator가 의존할 native `ClientToken`이 없다. +timeout/connection loss 뒤 동일 message를 재요청하면 duplicate를 배제할 provider contract가 +없다. + +`aws-ses-v2-durable-single-local-sns-v1`은 send 전에 만든 opaque non-PII ASCII/Base32 +`AttemptCorrelationId`를 고정 tag name `ca_attempt_v1`의 SES `EmailTags`에 넣는다. tag에는 +tenant/user/recipient/intent 의미를 인코딩하지 않는다. verified SES event의 matching tag와 +`SEND` fact는 response-loss attempt가 provider에 accepted되었음을 사후 복원할 수 있다. 이는 +provider dedupe/idempotency key가 아니며 동일 send 재요청을 안전하게 만들지 않는다. + +AWS SDK v2의 standard retry는 기본적으로 여러 attempt를 수행할 수 있으므로 mutation send +baseline에서는 disable하고 coordinator의 physical attempt journal을 사용한다. future provider +card가 SDK retry를 허용하려면 모든 actual attempt, backoff와 transmission certainty가 local +budget/evidence에 포함됨을 증명해야 한다. + +공식 기준: +[AWS SDK for Java 2.x retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html). + +### 19.7 quota, sandbox와 admission + +SES account/region의 sandbox 상태와 sending capability는 startup/readiness에서 exact profile로 +확인한다. quota 수치는 계정/region/상태에 따라 달라질 수 있으므로 문서에 고정 숫자를 박지 않고 +runtime control plane/readiness evidence를 사용한다. + +- send rate/24-hour quota에 맞춘 bounded token bucket; +- local backlog/expiry와 provider quota intersection; +- provider throttling의 bounded backoff; +- sandbox에서는 verified recipient/mailbox simulator만 사용; +- production access가 없으면 selected production card R2를 주장하지 않는다. + +공식 기준: + +- [SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html) +- [GetAccount API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetAccount.html) +- [Managing sending quota errors](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html) + +### 19.8 feedback event + +최소 R2 feedback topology를 다음 하나로 고정한다. + +```text +SES configuration set + -> one SNS standard topic + -> HTTPS endpoint owned by adapter-inbound-web + -> NormalizedNotificationReceiptUseCase + +SNS exhausted-delivery -> infrastructure-managed DLQ +``` + +card는 SES account/region/configuration set, SNS TopicArn/account/region, endpoint profile, +SignatureVersion 2, retry horizon/ACK contract와 DLQ identity를 고정한다. 같은 route에서 SES +identity notification과 configuration-set event publishing을 이중 활성화하지 않는다. + +event mapping: + +| SES event | immutable fact/projection | +| --- | --- | +| `SEND` | `SubmissionProjection=ACCEPTED`; EmailTag correlation으로 response-loss 복원 가능 | +| `REJECT` | accepted fact를 지우지 않고 `RecipientTransport=FAILED_AFTER_ACCEPT` | +| `BOUNCE` | `RecipientTransport=BOUNCED`; hard bounce만 suppression 후보 | +| `COMPLAINT` | `Abuse=COMPLAINED`, 다른 transport projection과 공존 | +| `DELIVERY` | `RecipientTransport=MTA_ACCEPTED` | +| `DELIVERY_DELAY` | normalized `DELAYED` fact/projection | +| `RENDERING_FAILURE` | provider-stored template card에서만 `FAILED_AFTER_ACCEPT`; local-rendered 초기 card는 기대 event set에 넣지 않음 | + +`DELIVERY`는 recipient mail server가 message를 accepted했다는 의미이며 inbox placement/read가 +아니다. exact expected event set은 deployed card에 고정하고 extra/unknown event는 quarantine한다. + +공식 기준: + +- [SES event destination](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_EventDestination.html) +- [Monitoring sending activity using notifications](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html) +- [SES event publishing and message tags](https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html) +- [SES SNS event examples](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-examples.html) +- [SES message insights](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetMessageInsights.html) + +### 19.9 bounce, complaint와 suppression + +- hard bounce와 complaint는 technical suppression 후보로 처리한다; +- transient/delayed event를 hard suppression으로 즉시 승격하지 않는다; +- provider/account/global suppression과 local technical suppression의 precedence를 명시한다; +- business unsubscribe/marketing consent와 별도 store/port를 유지한다; +- recipient HMAC scope와 encryption key lifecycle을 적용한다; +- suppression 충돌/해제는 audit 가능한 use case로만 수행한다. + +공식 기준: +[SES global suppression list](https://docs.aws.amazon.com/ses/latest/dg/sending-email-global-suppression-list.html). + +### 19.10 Gmail API와 SMTP의 위치 + +`gmail-api`: + +- Workspace/mailbox identity가 실제 요구일 때만 선택한다; +- OAuth user consent 또는 domain-wide delegation, per-user quota, message resource와 push + notification을 exact card에 포함한다; +- Gmail `users.messages.send` response를 recipient delivery receipt로 부르지 않는다; +- 기존 `google-email` 이름만으로 Gmail R2를 주장하지 않는다. + +공식 기준: + +- [Gmail users.messages.send](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) +- [Gmail API usage limits](https://developers.google.com/workspace/gmail/api/reference/quota) +- [Gmail API sending](https://developers.google.com/workspace/gmail/api/guides/sending) + +`smtp`: + +- SMTP는 provider가 아니라 transport profile로 다룬다; +- STARTTLS/TLS, AUTH, DSN, connection reuse, server response, timeout, duplicate semantics를 exact + provider card가 정의해야 한다; +- generic SMTP success를 inbox delivery/read로 표현하지 않는다; +- port 25 fallback, opportunistic TLS 또는 plaintext credential을 허용하지 않는다. + +## 20. receipt, reconciliation과 suppression workflow + +### 20.1 submission과 recipient outcome 분리 + +submission control과 recipient lifecycle fact를 별도로 보존한다. + +```text +submission: + appended -> wire-authorized -> accepted | definitely-rejected | indeterminate + +receipt facts: + SEND | REJECT | BOUNCE | COMPLAINT | DELIVERY | DELIVERY_DELAY + +orthogonal projections: + submission + recipientTransport + abuse + conversationPresence +``` + +provider accepted 뒤에도 bounce/complaint가 올 수 있으므로 receipt가 submission fact를 +덮어쓰지 않는다. complaint도 transport projection과 공존한다. summary projection은 모든 축을 +함께 보여준다. + +### 20.2 callback verification + +raw callback inbound adapter는 provider transport별로 다음을 적용한다. + +- raw body size/content-type/method limit; +- provider-defined signed representation에 대한 signature/authenticity 검증. raw bytes가 + signature contract인 transport만 exact raw bytes를 사용; +- provider retry semantics에 맞는 replay/dedupe window; +- expected account/topic/configuration set/workspace allowlist; +- verification key/secret rotation; +- constant-time comparison where applicable; +- event ID digest uniqueness; +- batch event count/depth/string limit; +- unknown schema/version quarantine; +- raw header/body/DTO를 application으로 넘기지 않음. + +검증 성공 뒤에만 `NormalizedNotificationReceiptCommand`를 만든다. + +SES R2의 SNS HTTPS ingress는 generic webhook secret으로 검증하지 않는다. + +- bounded JSON parse 뒤 SNS가 정의한 field canonical string을 구성하고 + `SignatureVersion=2`를 검증한다; +- `SigningCertURL`은 HTTPS, allowlisted AWS SNS host/path, DNS/IP/redirect와 certificate chain을 + 검증해 SSRF/host confusion을 막고 bounded cache/deadline으로 가져온다; +- exact TopicArn의 account/region/name과 canonical binding을 비교한다; +- exact SES card의 + `max_callback_age = bounded SNS HTTP retry horizon + bounded DLQ retention/redrive horizon + + allowed clock skew`를 checked-in ingress profile로 고정하고 Task 19가 실제 topology와 + 대조한다. initial `ses-notification-v1`은 각각 `1h + 7d + 5m = 7d1h5m`이고 outer tombstone은 + ingestion safety margin `1h`를 더 길게 덮는 `8d`, inner semantic tombstone은 `30d`다. 이 + window 안의 정상 delayed retry는 + `Timestamp`만으로 거부하지 않는다. allowed skew보다 미래인 timestamp와 window를 + 초과한 envelope는 signature가 유효해도 receipt/quarantine DB mutation 없이 4xx로 거부하고 + bounded security metric만 남긴다; +- outer SNS `MessageId`와 inner SES semantic fingerprint를 HMAC dedupe한다. 두 tombstone은 + `max_callback_age + ingestion safety margin`보다 길고, inner semantic tombstone은 승인된 manual + redrive window 전체를 덮는다; +- `SubscriptionConfirmation`/`UnsubscribeConfirmation`의 arbitrary `SubscribeURL`을 runtime에서 + 자동 fetch하지 않는다. IaC 또는 별도 인증·승인된 운영 절차가 exact TopicArn을 확인해 + subscription을 확정한다; +- normalized receipt transaction이 commit된 뒤에만 success ACK를 반환한다. transient failure는 + SNS retry를 유도하고 exhausted delivery는 configured DLQ에서 replay한다. +- original outer envelope가 max age를 지난 수동 DLQ replay는 public endpoint에 그대로 + 재주입하지 않는다. 별도 인증·승인된 운영 절차가 exact TopicArn으로 inner SES event를 + republish해 새 SNS outer `MessageId/Timestamp/signature`를 만들고, inner semantic fingerprint는 + 그대로 유지한다. semantic tombstone retention을 지난 replay는 projection을 변경하지 않는 + forensic 절차를 새로 승인하지 않는 한 거부한다. + +공식 기준: +[SNS message signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html), +[SNS HTTP delivery retry](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html). + +### 20.3 duplicate/out-of-order/orphan + +- 같은 provider event ID는 idempotent duplicate다; +- SNS outer retry는 `(TopicArn, MessageId)`로 dedupe하고 SES inner semantic fingerprint는 + versioned HMAC over `(provider binding revision, SES messageId, normalized event type, + one-recipient digest, documented provider occurrence discriminator)`로 계산한다; +- provider message reference와 event ID가 충돌하면 quarantine한다; +- accepted finalize보다 callback이 먼저 오면 `ORPHAN` inbox에 저장한다; +- later attach worker가 bounded window 동안 재매칭한다; +- reducer는 같은 verified fact set의 모든 arrival permutation에서 같은 projection을 만든다; +- semantic conflict만 provider semantics table에 따라 audit/quarantine하고 단순 out-of-order를 + 이전 state overwrite로 처리하지 않는다; +- callback retry 응답은 receipt transaction commit 여부와 일치시킨다. + +### 20.4 reconciliation + +reconciliation은 provider card가 명시적으로 지원하는 경우만 호출한다. + +```text +ReconcileOutcome = + CONFIRMED_ACCEPTED + CONFIRMED_NOT_APPLIED + STILL_IN_PROGRESS + STILL_INDETERMINATE + RECONCILIATION_UNSUPPORTED +``` + +absence가 not-applied를 증명하는지 provider별로 검증한다. list/history API에서 못 찾았다는 +사실만으로 definite failure를 만들지 않는다. + +reconcile call에도 별도 deadline, quota, max count와 total amplification budget을 적용한다. + +### 20.5 cancellation + +intent cancellation은 아직 시작하지 않은 delivery를 막는 best effort control이다. + +- `QUEUED/RETRY_WAIT`은 token/version guarded하게 cancel할 수 있다; +- `CLAIMED/ATTEMPT_RESERVED`도 `WIRE_AUTHORIZED` 전에 token/version guarded하게 cancel할 수 있다; +- `WIRE_AUTHORIZED/RECONCILE_WAIT/TERMINAL_INDETERMINATE/PROVIDER_ACCEPTED`를 not-sent로 + 바꾸지 않는다; +- cancel과 wire authorization 중 먼저 commit된 transition이 이긴다; +- Slack message delete나 email recall을 generic cancellation으로 약속하지 않는다; +- provider-specific delete/update는 별도 operation/capability card다. + +### 20.6 manual operation + +operator action은 다음 bounded use case만 허용한다. + +- inspect summary and safe reason codes; +- pause/resume route dispatcher; +- retry definite-not-sent terminal with new audited occurrence; +- request reconciliation; +- attach/quarantine orphan receipt; +- expire/redact payload; +- rotate/restore required template or encryption revision. + +raw DB state edit, arbitrary resend, suppression row 직접 삭제는 runbook 정식 동작이 아니다. + +## 21. canonical configuration + +### 21.1 source of truth + +현재 channel-wide selector와 legacy enabled boolean을 폐기하고 하나의 canonical graph로 +activation을 결정한다. + +개념 예: + +```yaml +app: + notification: + expected-state: disabled | configured + expected-binding-ids: [security-email, engineering-alerts] + expected-writer-generations: + security-email-v3: 12 + engineering-alerts-v2: 8 + bindings: + security-email: + notification-kind: security-alert-email + channel: email + expected-mode: durable-async + strategy: single + route-revision: security-email-v3 + template: security-alert-v4 + providers: [aws-ses-primary] + engineering-alerts: + notification-kind: engineering-alert-slack + channel: slack + expected-mode: best-effort-inline + strategy: single + route-revision: engineering-alerts-v2 + template: engineering-alert-v2 + providers: [slack-web-api-primary] + providers: + aws-ses-primary: + type: aws-ses-v2 + region: ap-northeast-2 + credential-ref: workload-role + expected-credential-source: web-identity + from-identity-ref: transactional-sender + configuration-set: notification-events-v1 + feedback: + type: sns-https + topic-arn: ${APP_NOTIFICATION_SES_TOPIC_ARN} + expected-signature-version: "2" + ingress-profile: ses-notification-v1 + dlq-ref: notification-events-dlq + slack-web-api-primary: + type: slack-web-api + workspace-ref: engineering-workspace + token-ref: slack-bot-primary + destination-ref: engineering-alert-channel +``` + +실제 properties type과 env mapping은 implementation plan에서 env-key registry와 validation +grammar를 함께 정의한다. 위 YAML은 의미 예시이며 현재 동작하는 설정이 아니다. + +checked-in `NotificationCanonicalRouteCatalog`가 writer fence route key의 retained SSOT다. 그 +key set은 canonical binding graph와 post-migration fresh provisioning의 reviewed initial key set에 exact +equality여야 한다. PRE-only `NotificationCutoverRouteCatalog`는 canonical key를 추가/삭제하지 +못하고 route별 legacy alias와 bounded current+retiring transport profile registry만 장식한다. +registry는 active admission profile 하나와 각 revision의 proof class +(`HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED`)/evidence revision을 고정한다. runtime canonical +target generation map은 별도 reviewed config/evidence revision이고 catalog key를 추가/삭제할 +수 없다. legacy settings나 현재 consumer 수는 이 set을 축소하지 못한다. production +consumer 0 또는 live legacy mapping이 없는 route도 PRE에서는 closed LEGACY predecessor fence로 +batch 초기화한 뒤 동일한 audited drain/complete를 거치며, direct canonical seed는 FINAL의 +independent issuer가 irreversible no-legacy-authority fence를 먼저 commit한 뒤 서명한 +DB-birth authorization을 exact two-function protocol로 검증한 +`notificationFreshProvisioning`에만 허용한다. bootstrap은 canonical +catalog key와 exact runtime target map을 retained +application-owned canonical route set으로 변환하고, PRE에서만 cutover proof decorator를 +transitional writer route set에 더한다. legacy/canonical composition, initializer와 migration +cross-check는 같은 key-set digest를 사용한다. + +permit acquire는 active profile만 사용하고 timeout, attestation과 COMPLETE는 request나 현재 +permit rows만으로 proof class를 추론하지 않는다. batch initialization이 compiled PRE +current+retiring registry를 retained +`notification_writer_transport_proof_registry`에 같은 root transaction으로 동결하고, 이후 PRE +runtime은 양쪽 exact equality를 검증한다. unknown/omitted historical blocking profile은 fail +closed하고 permit/attestation/operation history가 참조하는 retiring profile은 제거하지 못한다. +permit 0인 route도 persisted active profile이 `QUIESCENCE_REQUIRED`이면 attestation이 필수다. +`HARD_BOUND_PROVEN`은 reviewed deadline/ +cancellation integration evidence revision이 catalog와 qualification manifest에 일치할 때만 +허용하며, current R0 profile은 `QUIESCENCE_REQUIRED`다. + +### 21.2 `expected-state` + +```text +disabled + legacy config absent + -> PURE_DISABLED + -> canonical binding 0 + -> canonical/legacy provider, client, dispatcher, operator and table scan 0 + +disabled + exact legacy-only config + PRE_CUTOVER_BRIDGE artifact + -> PRE_LEGACY_BRIDGE + -> canonical binding/provider/store/worker 0 + -> transitional initializer/fence/permit/operator와 selected legacy provider만 구성 + -> audited initialization 전 legacy admission/provider call 0 + +configured + -> CANONICAL_CONFIGURED + -> actual binding ID set == expected-binding-ids + -> compiled route-revision key set == expected-writer-generations key set + -> FINAL/REQUIRE_CANONICAL이면 persisted fence set == ACTIVE/CANONICAL exact target set + -> PRE이면 exact predecessor/target closed state set을 허용하고 route별 owner match만 admission + -> 모든 required provider/template/store/worker가 exact graph를 만족 + -> 누락/unknown/mismatch면 startup failure +``` + +blank selector를 암묵적으로 disabled로 해석하는 것과 운영자가 configured를 기대했는데 실제 +binding 0인 것을 구분한다. configured에서 missing/extra binding 하나라도 실패하며 “하나 이상” +검사로 필수 SES binding의 소실을 숨기지 않는다. release artifact에는 sorted exact graph의 +manifest digest와 evidence revision도 남긴다. canonical configured와 어떤 legacy key도 같은 +process에서 공존할 수 없다. PRE legacy bridge는 pure disabled의 예외가 아니라 별도 closed +composition state이고 FINAL artifact에서는 존재할 수 없다. + +writer generation map은 runtime cutover revision assertion이며 secret이 아니다. route key는 +retained canonical catalog에서만 오고 값은 canonical target generation이다. legacy predecessor는 +`target - 1`이며 missing/extra/unknown/overflow를 허용하지 않는다. abort로 generation이 바뀌면 +config/evidence revision도 바뀌며 canonical node는 새 exact set으로 재배포되기 전 fail +closed한다. dark legacy bridge는 같은 map의 predecessor set을 batch init에 사용하되 fence +absent/partial 상태에서는 startup endpoint만 열고 provider admission은 0이다. + +PRE artifact에서 canonical-only graph가 exact catalog key set과 persisted closed predecessor/ +target state를 만나면 bootstrap이 `CUTOVER_WAIT`를 내부적으로 derive한다. request, env 또는 +generic property로 이 mode를 선택할 수 없다. process liveness는 유지하지만 predecessor/ +DRAINING route의 admission, claim, provider call은 0이고 notification readiness는 +`CUTOVER_WAIT`다. route가 committed `ACTIVE/CANONICAL@target`이 된 것을 fresh DB read로 확인한 +뒤에만 그 route를 동적으로 연다. FINAL artifact는 CUTOVER_WAIT production branch를 제거하고 +모든 route가 exact canonical target이 아니면 startup을 실패시킨다. + +### 21.3 binding compile + +notification-local compiler 결과와 application compatibility validator가 합성한 최종 +composition contract는 다음 exact tuple이다. + +```text +effective binding = + notification kind + + application NotificationKindPolicy revision/mode/admission class + + config expected-mode assertion + + route revision + + channel + + strategy + + template/version/checksum/locale set + + ordered provider target revisions + + required provider capability + + attempt/reconcile/amplification limits + + receipt expectation + + provider-local runtime profile +``` + +notification-local compiler가 검증할 항목: + +- code catalog에 없는 route/template/provider type 거부; +- duplicate/empty/cyclic binding 거부; +- channel/provider mismatch 거부; +- durable route + legacy/fail-open provider 거부; +- receipt-required route + receipt-unsupported provider 거부; +- fallback + indeterminate-unsafe chain 거부; +- bound 초과의 target/retry/reconcile 거부; +- unknown properties fail closed. + +application pure compatibility validator와 bootstrap composition이 별도로 검증할 항목: + +- `NotificationKindPolicy.mode`와 config `expected-mode` 불일치; +- actual/expected binding ID exact set와 release manifest digest; +- persistence store/schema/crypto descriptor와 durable policy; +- receipt-required card와 inbound SNS ingress descriptor; +- send/receipt slice의 account/region/configuration set/topic identity 일치; +- live/retained intent가 참조하는 모든 revision의 가용성; +- required worker/readiness/evidence manifest의 exact composition. + +notification-local compiler가 persistence/inbound sibling을 탐색하지 않는다. bootstrap은 §13.1의 +provider-neutral descriptor를 application validator에 전달할 뿐 business/retry/fallback 정책을 +settings/configuration class에 구현하지 않는다. + +### 21.4 legacy migration + +legacy: + +```text +app.notification.slack.provider +app.notification.email.provider +app.notification.slack-webhook.enabled +app.notification.google-email.enabled +app.notification.routes.* +``` + +canonical graph와 legacy key가 동시에 나타나면 값이 같더라도 startup을 실패시킨다. temporary +migration translator를 두더라도 한 방향으로만 변환하고 deprecation telemetry와 removal +deadline을 둔다. + +## 22. activation과 zero-resource contract + +`PURE_DISABLED`, 즉 canonical `expected-state=disabled`이면서 legacy key도 없는 상태에서 다음이 +0이어야 한다. + +- Slack/AWS/Gmail/SMTP client; +- provider credential resolution; +- HTTP connection/pool; +- dispatcher scheduler/thread/executor; +- claim/reconcile/reaper scan; +- rate limiter bucket background task; +- provider startup network probe; +- provider health indicator; +- callback subscription expectation; +- application/runtime notification table DML과 scan. + +framework가 settings metadata 또는 harmless validator를 생성하는 것은 가능하지만 external +resource/secret/worker side effect는 없어야 한다. +expand-first V7 DDL과 V8의 structural classification은 feature flag로 gate하지 않는 migration +lifecycle이므로 이 runtime zero-resource 계수에서 제외한다. V8이 +`AWAITING_SIGNED_FRESH_PROVISIONING`을 남긴 경우 별도 provisioning job 전까지 normal +application startup/readiness, bean/resource/DML/scan/provider call은 0이어야 한다. +`notificationFreshProvisioning`은 issuer가 먼저 commit한 irreversible +no-legacy-authority fence가 포함된 signed DB-birth authorization을 받고, 같은 provisioner +transaction에서 snapshot/read-lock -> Java verifier -> apply 순서로 실행하는 exact one-shot +deployment operation이며 runtime zero-resource 경로에 포함하지 않는다. + +`PRE_LEGACY_BRIDGE`는 zero-resource 상태가 아니다. canonical provider/store/dispatcher/ +readiness resource는 0이지만 exact legacy provider와 transitional initializer/fence/permit/ +operator surface는 의도적으로 존재한다. batch initialization 전에는 그 surface도 provider call +0이며, 이후 legacy send는 committed permit을 반드시 거친다. composition/zero-resource/ +fenced-legacy tests는 PURE_DISABLED, PRE_LEGACY_BRIDGE와 CANONICAL_CONFIGURED를 별도 fixture로 +검증하고 same-process legacy+canonical overlap을 거부한다. + +한 channel만 binding되면 다른 channel provider/client는 생성하지 않는다. durable binding 없이 +best-effort만 있으면 persistence dispatcher를 만들지 않는다. feedback-required SES binding이 +없으면 receipt reconciliation worker를 만들지 않는다. + +optional consumer가 port를 호출했는데 해당 route binding이 없으면 +`NotificationCapabilityUnavailable` 같은 typed failure를 반환한다. silent no-op bean을 만들지 +않는다. + +## 23. deadline, resource와 capacity + +### 23.1 deadline을 분리한다 + +| deadline/window | 의미 | +| --- | --- | +| append deadline | business transaction 안 intent 저장 한도 | +| dispatch eligibility | `notBefore` | +| claim lease | worker ownership 한도 | +| per-attempt deadline | 한 authorized provider call의 monotonic budget | +| retry horizon | first eligibility부터 retry 가능한 총 기간 | +| intent expiry | 이후 새 send를 시작하지 않는 business limit | +| reconciliation horizon | unknown attempt를 확인할 최대 기간 | +| receipt window | delayed feedback를 attach할 기간 | +| payload retention | encrypted recipient/parameter 보존 기간 | +| dedupe tombstone | duplicate source/callback을 막을 보존 기간 | + +이 값을 하나의 `timeout`으로 합치지 않는다. caller deadline과 route maximum의 intersection을 +사용하고 wall-clock rollback이 elapsed attempt budget을 늘리지 않도록 monotonic time을 +사용한다. durable scheduling timestamp는 DB/UTC wall time을 사용하되 elapsed attempt deadline과 +구분한다. + +### 23.2 concurrency + +bounded control: + +```text +global dispatcher concurrency +per-provider concurrency +per-account/workspace concurrency +per-destination/channel rate bucket +claim batch size +max in-memory rendered bytes +max outstanding attempts +max receipt batch/events +``` + +virtual thread를 사용해도 admission 상한이 사라지지 않는다. provider client 내부 queue와 local +worker queue를 모두 유한하게 둔다. + +### 23.3 backpressure + +- DB backlog가 높으면 claim batch/concurrency를 bounded하게 조절한다; +- provider quota가 낮아도 hot-loop claim/release를 하지 않는다; +- retry storm에 full jitter를 사용한다; +- critical route와 bulk/low-value route의 admission partition을 분리할 수 있다; +- priority가 starvation을 만들지 않도록 aging/weight를 명시한다; +- expiry 가까운 intent를 무조건 먼저 보내 privacy/consent를 우회하지 않는다; +- overload 시 best-effort와 durable append 정책을 별도로 정의한다. + +### 23.4 capacity equation + +최소 capacity review는 다음을 계산한다. + +```text +incoming durable intents/sec +× 1 logical recipient per intent +× provider legs per recipient +× expected physical attempts ++ reconciliation calls ++ callback events +``` + +worst-case는 route별 amplification cap으로 계산한다. provider advertised throughput만 보지 않고 +DB claim/finalize TPS, encryption/render CPU, callback burst와 retention storage를 함께 측정한다. + +## 24. security, privacy와 retention + +### 24.1 data classification + +| 데이터 | 기본 분류 | 저장 | +| --- | --- | --- | +| recipient address/channel mapping | PII/secret 가능 | versioned direct-AEAD ciphertext + lookup HMAC | +| template parameter | PII/business secret 가능 | versioned direct-AEAD ciphertext | +| rendered body/subject | PII/business secret 가능 | 기본 미저장, 필요 시 짧은 encrypted retention | +| provider token/webhook URL/AWS credential | secret | secret manager reference only | +| provider message reference | high-cardinality, 간접 PII 가능 | encrypted/opaque + optional HMAC | +| intent/delivery/attempt opaque ID | internal identifier | log 허용, metric tag 제한 검토 | +| route/template/provider/reason code | bounded operational metadata | log/metric 허용 | + +### 24.2 versioned direct AEAD와 key ownership + +최소 R2는 “envelope encryption”을 주장하지 않고 versioned direct AEAD를 선택한다. + +- algorithm profile은 `DIRECT_AEAD_AES_256_GCM_V1`로 고정하고 field마다 CSPRNG 96-bit nonce와 + 128-bit authentication tag를 사용한다. 같은 key에서 nonce 재사용을 허용하지 않는다; +- canonical AAD는 versioned length-prefix encoding으로 + `schema/table + record ID + notification ID + optional delivery/attempt ID + field purpose + + provider binding revision + crypto profile version`을 묶어 row/field swapping을 막는다; +- DB에는 ciphertext/tag, nonce, algorithm/profile, non-secret key reference와 key version만 + 저장한다. raw key/credential은 settings record, application value, persistence entity, log, + metric, backup/export에 넣지 않는다; +- 새 encrypt는 current key, decrypt는 live/retained row가 참조하는 current/retiring exact + version을 사용한다. retiring row는 decrypt-reencrypt migration과 evidence 뒤 제거한다; +- 검색/dedupe는 별도 purpose-separated keyed HMAC과 §10.2의 alias/re-HMAC rotation protocol을 + 사용한다; +- old AEAD/HMAC key 폐기 전 active/backlog뿐 아니라 retained intent, suppression, callback + dedupe, orphan, message-reference lookup, tombstone과 backup retention을 scan한다. + +현재 `SecretSource`는 `app-bootstrap` 소유이며 `Optional`을 반환하므로 +notification/persistence leaf가 이를 import하지 않는다. 각 consuming adapter는 +`CredentialMaterialProvider` 또는 `PayloadKeyMaterialProvider` 같은 최소 framework-free +factory/handle contract를 자기 leaf에 두고, bootstrap이 canonical secret reference를 현재 +`SecretSource`로 resolve해 bridge를 구현한다. 반환 material은 version이 붙은 +`AutoCloseable` char/byte handle로 adapter 내부에서만 짧게 사용하고 close 시 wipe한다. 장기 +provider client가 credential refresh를 요구하면 reference 기반 provider가 매번 새 handle을 +받고 generation을 검증한다. + +settings에는 secret reference만 남긴다. bootstrap은 adapter factory를 조합할 수 있지만 adapter는 +bootstrap type에 의존하지 않는다. current string-based `SecretSource`에서 생기는 immutable +String copy 최소화/wiping 한계와 binary secret 지원은 구현 계획의 bootstrap 변경·테스트 +항목으로 명시한다. key rotation은 current/retiring handle factory를 원자 교체하고 기존 +in-flight attempt가 frozen credential/key generation을 잃지 않는 protocol로 검증한다. + +### 24.3 safe value type + +recipient/parameter/provider response value의 `toString()`은 redacted 형태여야 한다. Java record의 +자동 `toString()`에 raw email/body가 노출되는 현재 `Notification`을 durable path에서 재사용하지 +않는다. + +exception message, assertion failure, structured log argument, span event에도 raw value를 넣지 +않는다. debug profile도 이 원칙을 완화하지 않는다. + +### 24.4 retention + +retention class는 notification kind가 고정한다. + +- terminal 뒤 provider retry/reconciliation에 필요 없는 ciphertext를 먼저 redact/delete한다; +- dedupe digest/tombstone은 source retry와 provider/callback replay window보다 길게 유지한다. + SES/SNS outer/semantic tombstone은 exact + `max callback age + ingestion safety margin`보다 길고 semantic tombstone은 승인된 manual + redrive horizon도 덮어야 한다; +- recipient/message-reference/correlation digest는 SNS retry/DLQ와 orphan attach window가 끝날 + 때까지 유지한다. indefinite suppression은 ciphertext 삭제 전에 current-key alias/re-HMAC을 + 완료한다; +- dead/indeterminate row가 PII 무기한 보관 수단이 되지 않게 maximum retention을 둔다; +- orphan receipt evidence는 bounded attach window 뒤 quarantine summary만 남긴다; +- audit상 content 보존이 필요하면 목적/기간/access/key deletion을 별도 승인한다; +- delete는 delivery fact/aggregate metric과 content ciphertext를 분리한다. + +### 24.5 email security + +- verified sender identity를 route에 고정한다; +- SPF, DKIM, DMARC alignment와 bounce/complaint monitoring을 production readiness에 포함한다; +- marketing/상업성 email에 필요한 unsubscribe header/one-click semantics는 legal/product policy와 + 함께 별도 notification kind에서 강제한다; +- header injection, display-name spoofing, external link policy를 test한다; +- SES account/region/configuration set drift를 readiness에서 검출한다. + +### 24.6 Slack security + +- bot token과 webhook URL을 secret으로 취급한다; +- route가 고정한 workspace/channel 외 전송을 막는다; +- public-wide posting scope와 user token/impersonation을 default로 사용하지 않는다; +- Block Kit link/mention/metadata에 secret/PII를 넣지 않는다; +- token rotation/revocation 뒤 old generation의 in-flight outcome을 indeterminate로 잘못 + downgrade하지 않는다. + +### 24.7 callback security + +provider event가 직접 suppression 또는 delivery를 바꾸므로 callback은 일반 telemetry webhook이 +아니다. signature 검증 실패, unexpected topic/account/workspace, transport contract에 어긋난 +timestamp/replay, oversize, schema drift는 성공으로 흘려보내지 않고 bounded quarantine/metric을 +남긴다. SNS는 exact max-callback-age 안의 늦은 정상 retry를 timestamp만으로 거부하지 않고 +message/semantic dedupe를 사용한다. 그 age를 넘긴 signed outer envelope는 mutation 없이 +거부하고, manual redrive는 새 outer envelope와 보존된 inner semantic fingerprint를 요구한다. + +## 25. observability + +### 25.1 metrics + +허용할 bounded tag 예: + +```text +channel +provider_type/provider_binding +route_id +template_id/version +mode +strategy +outcome/reason_code +attempt_bucket +readiness_card_revision +``` + +금지 tag: + +```text +recipient/address +body/subject/parameter +provider message ID +Slack channel ID/workspace ID raw value +tenant/user/source operation raw ID +intent/idempotency/correlation ID +token/webhook/endpoint +exception message +``` + +핵심 metric: + +```text +notification_intent_append_total +notification_delivery_backlog +notification_oldest_eligible_age +notification_attempt_total +notification_attempt_duration +notification_indeterminate_total +notification_retry_scheduled_total +notification_reconcile_total +notification_receipt_total +notification_orphan_receipt_total +notification_suppression_total +notification_payload_redaction_lag +notification_claim_conflict_total +notification_expired_total +notification_admission_gate_park_total +notification_parked_delivery_count +``` + +summary `success rate`는 submission/recipient outcome을 섞지 않고 별도 metric으로 표시한다. + +### 25.2 traces + +권장 span: + +```text +notification.request +notification.intent.append +notification.dispatch.claim +notification.render +notification.provider.attempt +notification.reconcile +notification.receipt.verify +notification.receipt.apply +notification.retention.redact +``` + +durable worker는 persisted trace link/correlation을 사용하며 원래 request span을 며칠간 parent로 +열어두지 않는다. baggage를 provider request에 자동 전파하지 않는다. + +### 25.3 logs + +structured log에는 opaque internal ID와 bounded codes만 쓴다. + +```text +intent_id +delivery_id +attempt_id +channel +route_id +provider_binding +state_from/state_to +reason_code +claim_owner_hash [필요 시] +``` + +provider raw response/error payload, recipient/content, credential, message reference는 기본 log +금지다. 필요 evidence는 allowlisted parsed code와 short digest로 남긴다. + +### 25.4 audit + +다음 action은 audit 대상이다. + +- route/template/provider revision activation; +- route writer `INITIALIZE_LEGACY`, `INITIALIZE_CANONICAL_FRESH`, `BEGIN_DRAIN`, + `TERMINALIZE_EXPIRED_PERMITS`, `COMPLETE_SWITCH`, `ABORT_DRAIN`; +- retained signed writer inventory/quiescence evidence, fresh-install provenance와 finalization + discriminator/provisioning; +- critical route pause/resume; +- manual retry/reconcile/cancel; +- suppression add/remove; +- orphan receipt attach/quarantine; +- encryption/template old revision retirement; +- operator payload access/redaction override. + +audit에는 actor/authorization/reason/revision과 opaque target만 기록하고 raw notification content를 +복제하지 않는다. + +## 26. startup, health와 readiness + +### 26.1 liveness + +application liveness는 Slack/SES/network/DB backlog와 독립이다. provider outage나 quota exhaustion +때문에 process liveness를 실패시켜 restart loop를 만들지 않는다. + +### 26.2 startup validation + +startup에서 network send 없이 다음을 검증한다. + +- `expected-state`, `expected-binding-ids`와 canonical graph manifest; +- notification-local code catalog와 config route/provider/template revision; +- template asset checksum/schema/locale/output static bounds; +- provider credential/identity reference의 존재와 형식; +- application validator가 받은 persistence/crypto/worker와 inbound receipt descriptor; +- route mode/strategy와 provider capability compatibility; +- retry/fallback/amplification bound; +- callback-required route의 send/receipt profile identity 일치; +- live/retained intent가 참조하는 모든 revision 가용성; +- legacy/canonical key conflict; +- compiled cutover route key set, canonical target generations와 persisted predecessor/target + fence state machine, retained signed inventory/attestation/provenance/finalization integrity와 + Java Ed25519 재검증; +- disabled/zero binding resource 0. + +notification-local compiler는 sibling bean/store/controller를 직접 탐색하지 않는다. +`app-bootstrap`이 각 adapter descriptor를 application의 pure compatibility validator에 전달해 +최종 composition을 판정한다. + +실제 provider credential validity/account state를 확인하는 network probe는 startup bean +construction과 분리한다. provider outage가 process boot를 무한 지연시키지 않도록 finite deadline, +cache와 readiness semantics를 둔다. + +### 26.3 readiness + +readiness는 active required binding만 평가한다. + +```text +required binding ready = + compiled graph valid + AND actual binding IDs exactly match expected set + AND required template revisions loaded + AND durable store reachable/schema compatible [durable only] + AND encryption/key refs usable + AND provider account/profile check acceptable + AND required route/provider/account admission gates ACTIVE + AND dispatcher admission running [durable only] + AND callback topology expected state met [receipt-required only] +``` + +PRE canonical-only node가 exact predecessor/DRAINING state를 관측하면 application liveness는 +healthy지만 notification readiness는 `CUTOVER_WAIT`이고 해당 route admission/worker/provider +call은 0이다. exact canonical target으로 committed 전이한 route만 fresh read 뒤 활성화한다. +partial/extra/unrelated generation, owner drift 또는 rollback generation 변화는 즉시 route를 +닫고 readiness를 내린다. FINAL은 wait state가 없으며 exact all-canonical set이 아니면 startup +failure다. + +best-effort optional binding outage가 전체 service readiness를 실패시킬지는 bootstrap의 reviewed +required/optional policy가 정한다. global “all notification provider healthy” boolean로 +합치지 않는다. + +### 26.4 provider health probe + +- 실제 user/channel/email에 synthetic message를 보내지 않는다; +- Slack `auth.test`는 token/team/bot identity 확인에만 사용하고 health를 위해 read scope를 + 추가하지 않는다. channel write access는 sandbox qualification 또는 실제 bounded send + evidence로 증명하며, route 기능에 필요하지 않은 `conversations.info/history` scope를 health + 전용으로 요구하지 않는다; +- SES는 account sending status/quota/identity/configuration set을 safe control-plane call로 + 확인하고 exact resolved credential source/account/region을 card와 비교한다; +- network probe는 bounded cache/jitter를 사용한다; +- probe failure를 send outcome으로 사용하지 않는다; +- disabled provider는 probe하지 않는다; +- readiness component 이름/tag에 secret/destination raw ID를 넣지 않는다. + +### 26.5 backlog health + +provider reachable 여부와 별도로 다음을 본다. + +- oldest eligible delivery age; +- retry/reconcile lag; +- expired-before-attempt rate; +- indeterminate accumulation; +- orphan receipt accumulation; +- payload redaction/key retirement lag; +- claim conflict/stale lease rate; +- provider quota headroom. +- parked admission gate/leg count와 oldest parked age. + +health threshold는 alert/runbook 신호이며 liveness restart trigger로 자동 재사용하지 않는다. + +## 27. lifecycle와 deployment safety + +### 27.1 startup order + +```text +settings bind/validate + -> catalog/template manifest load + -> binding compile + -> store schema/key/provider dependency validate + -> provider clients construct + -> readiness components register + -> dispatcher admission open +``` + +compile 실패 뒤 일부 provider client/worker를 남기지 않는다. + +### 27.2 graceful shutdown + +1. 신규 claim/admission을 닫는다; +2. 이미 claim했지만 send 전인 row를 safe release 또는 lease expiry 대상으로 표시한다; +3. in-flight attempt를 bounded grace 동안 기다린다; +4. wire call을 취소했더라도 possible-send는 reconcile path 또는 + `TERMINAL_INDETERMINATE`로 finalize하려 시도한다; +5. finalize 실패 시 lease/reaper가 reconcile path로 보내도록 durable evidence를 남긴다; +6. callback intake는 load balancer drain과 transaction completion 순서를 맞춘다; +7. provider client/executor를 닫는다. + +shutdown timeout 뒤 interrupt를 `DEFINITELY_NOT_APPLIED`로 해석하지 않는다. + +### 27.3 rolling deployment + +- expand schema가 구/신 version 모두와 호환된 뒤 code를 배포한다; +- 모든 live/retained intent가 참조하는 route/template/provider/renderer revision을 backlog와 + retention horizon 동안 유지한다; +- old worker와 new worker가 같은 row를 처리해도 owner token/version이 stale finalize를 막는다; +- state enum 추가는 unknown value로 old node가 row를 손상하지 않게 rollout한다; +- credential/key/template revision retirement는 active/backlog/retention scan 뒤 진행한다; +- rollback 가능한 기간 동안 new-only state와 ciphertext를 old code가 읽지 못하는 문제를 + 검증한다. + +### 27.4 clock + +- persisted schedule/expiry/provider occurred time은 UTC instant로 저장한다; +- elapsed provider deadline은 monotonic source를 쓴다; +- DB claim eligibility가 DB clock인지 application clock인지 하나로 고정한다; +- provider callback timestamp는 trusted ordering evidence로 바로 사용하지 않고 verification + window와 server received time을 함께 기록한다; +- NTP drift alert를 운영 prerequisite에 둔다. + +## 28. error taxonomy와 application mapping + +### 28.1 stable internal reason + +reason code family: + +```text +CONFIGURATION_* +CAPABILITY_UNAVAILABLE +INTENT_DUPLICATE +INTENT_FINGERPRINT_MISMATCH +BUSINESS_POLICY_REJECTED +TEMPLATE_* +RECIPIENT_* +ADMISSION_* +PROVIDER_THROTTLED +PROVIDER_AUTHORIZATION_REJECTED +PROVIDER_REQUEST_REJECTED +PROVIDER_ACCEPTED +PROVIDER_RESPONSE_INDETERMINATE +BINDING_PARKED +BINDING_RESUMED +RECONCILIATION_* +RECEIPT_* +SUPPRESSED_* +CLAIM_* +ENCRYPTION_* +EXPIRED +``` + +provider raw error code는 allowlisted mapping table을 통과해 stable reason code가 된다. unknown +provider error text를 exception/log/metric에 복제하지 않는다. + +### 28.2 application failure + +application-facing error는 대략 다음으로 제한한다. + +```text +NotificationCapabilityUnavailable +NotificationRequestRejected +NotificationIntentConflict +NotificationIntentPersistenceFailure +NotificationDispatchConflict +NotificationOutcomeIndeterminate +``` + +feature use case는 자신의 failure policy에 따라 이를 business error 또는 asynchronous operational +state로 mapping한다. controller가 provider status/SDK exception을 직접 mapping하지 않는다. + +### 28.3 inbound error + +callback inbound adapter는 signature/auth/size/schema failure를 transport status로 정확히 반환하되 +raw reason을 외부에 과다 노출하지 않는다. verified duplicate는 idempotent acknowledgement, +transient store failure는 provider retry를 유도하는 response, permanent invalid event는 provider +contract에 맞는 bounded response로 mapping한다. + +## 29. test, CI와 evidence strategy + +### 29.1 application-core test + +- feature-specific port가 reviewed kind/route/mode만 선택; +- consent/preference/quiet-hours/not-before/expiry; +- source operation idempotency와 fingerprint mismatch; +- typed parameter/recipient value validation과 redacted `toString()`; +- best-effort와 durable result 의미; +- dispatch state transition table; +- definite/retryable/permanent/indeterminate decision; +- `PARK_BINDING`과 initial fallback hold policy; +- fallback activation과 block; +- total amplification budget; +- cancellation/expiry와 maybe-sent 보존; +- callback duplicate/orphan/out-of-order command semantics. + +Spring, provider SDK, persistence entity 없이 fake port/clock을 사용한다. + +### 29.2 notification adapter test + +- code catalog와 canonical binding compiler; +- duplicate/unknown/mismatch/legacy conflict; +- SINGLE/FAN_OUT_ALL/ORDERED_FALLBACK; +- frozen plan/revision compatibility; +- template manifest/checksum/schema/locale fallback; +- text/HTML/Slack escaping과 injection property test; +- size/count/depth/control-character limit; +- provider error/outcome exact mapping; +- SDK hidden retry 0 또는 physical attempt count evidence; +- deadline/cancellation/response-loss indeterminate; +- provider descriptor/card compatibility; +- disabled/partial binding zero client/thread/probe; +- no PII/secret log, metric tag, exception. + +### 29.3 persistence-jpa integration test + +real PostgreSQL에서 다음을 검증한다. + +- business write + intent append same transaction commit/rollback; +- `TransactionPort.inRootWrite` physical commit-before-return과 ambient transaction fail-fast; +- outer REQUIRED transaction 안 best-effort 호출 rejection/rollback 시 provider call 0; +- same idempotency/same fingerprint와 mismatch; +- encrypted payload와 plaintext absence; +- concurrent `SKIP LOCKED` claim; +- owner token + expected state/version finalize; +- stale worker conflict; +- claim crash before/after `WIRE_AUTHORIZED`; +- lease 만료 뒤 늦은 exact provider result의 terminal-once append/projection merge; +- provider accepted 뒤 finalize failure; +- retry/backoff/expiry query; +- fan-out partial state; +- fallback activation atomicity; +- multi-node gate park CAS, restart persistence와 audited resume; +- park/resume/fallback/expiry 경쟁; +- cancellation/expiry/suppression과 wire authorization 경쟁; +- duplicate/out-of-order/orphan receipt; +- callback receipt apply와 delivery projection transaction; +- retention/redaction, AEAD/HMAC rotation 전후 dedupe/suppression matching; +- indexes/query plan/backlog capacity. + +H2-only test로 PostgreSQL lock/concurrency evidence를 대체하지 않는다. + +### 29.4 inbound callback test + +- provider-defined signed representation authenticity; +- SNS SignatureVersion 2 canonical string, cert URL/chain/SSRF와 delayed retry; +- current/previous verification key rotation; +- unexpected account/topic/workspace; +- oversize/content-type/schema/depth; +- batch partial invalid event policy; +- duplicate acknowledgement; +- transient store failure response; +- no raw DTO/SDK type escape; +- no sensitive body logging. + +### 29.5 app-bootstrap composition test + +- expected-state와 expected-binding-id exact set; +- canonical graph only; +- binding별 bean/client/worker/readiness exact count; +- durable binding에 store/worker/key 누락 시 startup failure; +- synchronous best-effort use case의 root transaction port wiring; +- receipt-required binding에 callback topology 누락 시 failure; +- legacy/canonical conflict; +- shutdown order와 in-flight classification; +- selected provider dependency/classpath absence failure; +- environment key registry와 sample/default YAML alignment. +- FINAL startup/readiness가 application retained-evidence read use case만 호출하고 + app-bootstrap repository/entity/JDBC 직접 접근이 0임; +- cleanup 뒤 FRESH/UPGRADE full bounded evidence read와 Java verifier wiring이 유지되고 + forged/mismatched retained row에서는 readiness/provider I/O가 0임. + +### 29.6 local protocol/fault test + +real provider 호출 없는 deterministic server/fake에서: + +- exact request auth/header/body mapping; +- response status/body/error mapping; +- 429/retry-after; +- timeout before connect/during possible write/after response; +- truncated/malformed success response; +- connection reset; +- provider SDK actual invocation count; +- cancellation and client resource close; +- request body/response log redaction. + +mock이 provider semantics를 창작하지 않도록 fixture는 공식 contract의 allowlisted case만 구현한다. + +### 29.7 Slack real-provider lane + +real network test는 `:adapter:outbound:notification:test`에 넣지 않는다. 그 focused test는 항상 +offline deterministic test이며 credential/network 유무에 따른 skip/pass가 없어야 한다. +`app-bootstrap` 소유의 명시적 opt-in `notificationReadiness` source set/harness가 별도 developer +sandbox/workspace/channel에서 다음 safe smoke를 실행한다. + +- real `chat.postMessage`; +- returned channel/ts와 optional conversation presence; +- message rendering/escaping; +- no production workspace/token; +- cleanup/update/delete가 필요한 test message lifecycle. + +invalid scope/channel/auth, 429/`Retry-After`, timeout/response loss는 local official-contract +protocol/fault suite에서 deterministic하게 매번 검증한다. 실제 channel throttling, credential +rotation/revocation은 승인된 scheduled/manual destructive drill로 분리한다. + +이 ownership을 구현할 때 notification leaf `CLAUDE.md`의 “no real network calls”는 focused +module test에 계속 적용되며, app-bootstrap opt-in readiness harness의 소유권과 금지 범위를 +함께 문서화하는 변경을 implementation deliverable로 포함한다. + +### 29.8 SES real-provider lane + +같은 app-bootstrap opt-in harness가 격리 AWS account/region, SES sandbox와 mailbox +simulator/verified recipient에서 다음 safe smoke를 실행한다. + +- account/sandbox/sending state; +- real `SendEmail`와 MessageId; +- EmailTag correlation과 SNS `SEND/DELIVERY/BOUNCE/COMPLAINT` 중 card의 safe deterministic + simulator case; +- IAM least privilege; +- exact credential source/account/region/configuration set/topic; +- no production recipient; +- feedback configuration set/account drift. + +invalid identity/auth/recipient, throttling/quota와 duplicate/out-of-order callback은 local +protocol/inbound fault suite에서 검증한다. 실제 quota pressure, credential/key rotation, +`DELIVERY_DELAY`, DLQ replay와 provider outage는 scheduled/manual drill로 분리한다. +local-rendered card에는 invalid provider template/`RENDERING_FAILURE` drill을 요구하지 않는다. + +실제 inbox placement/read를 acceptance로 사용하지 않는다. + +### 29.9 qualification evidence policy + +provider qualification은 세 lane으로 분리한다. + +1. safe real-provider smoke: exact sandbox profile에서 release/candidate마다 실행; +2. deterministic protocol/fault: offline focused/integration test에서 모든 build에 실행; +3. destructive/rotation/quota/delay drill: schedule과 승인된 manual run으로 실행. + +required lane은 secret/profile 부재를 “통과” skip으로 바꾸지 않는다. +`notificationProductionReadiness`는 exact card ID, binding/account/region/workspace, source commit, +test artifact, 실행 시각, lane type과 expiration을 가진 evidence manifest를 검증한다. freshness +window가 지났거나 required manifest가 없으면 `NOT_QUALIFIED` 또는 aggregate failure다. 한 lane의 +evidence를 다른 card/profile로 재사용하지 않는다. + +모든 manifest에는 build artifact에서만 파생한 immutable +`release_stage = PRE_CUTOVER_BRIDGE | FINAL_CLEANUP` 축을 포함한다. Gradle build가 compiled +production class/resource inventory, production dependency lock/source digest와 artifact digest로 +구조 manifest를 만들고 detector가 이를 판정한다. caller나 environment가 stage를 override할 수 +없다. 두 stage 모두 additive V7 +transport-proof-registry/permit/attestation/operation journal/history resource를 보존하며 +table/column/migration 문자열 자체는 executable legacy marker가 아니다. legacy path와 fenced +bridge, PRE cutover catalog/route set, initializer/switch/permit/terminalizer/attestation +class/bean/controller와 세 operator permission surface가 모두 존재하고 final cleanup migration이 없을 때만 +`PRE_CUTOVER_BRIDGE`, 그 executable surface와 CUTOVER_WAIT production branch/role mapping이 모두 +없고 retained canonical catalog/route set/graph, retained V7과 reviewed cleanup migration이 있을 +때만 `FINAL_CLEANUP`이다. +일부만 남은 +mixed/unknown artifact는 manifest를 발급하지 않는다. final aggregator는 +`PRE_CUTOVER_BRIDGE` evidence를 cleanup artifact에 재사용하지 않는다. + +detector가 비교할 legacy class/config marker 이름은 verification source와 reviewed detector +test allowlist에 명시적으로 남긴다. production consumer-zero 검사는 registered production +leaf의 `src/**/src/main` tree와 production +config만 대상으로 하고, 별도 allowlist test가 detector marker의 complete set과 allowlist 밖 +reference 0을 검증한다. 문자열 분할/난독화로 hygiene scan을 피하지 않는다. + +ownership setup도 release stage별로 닫힌 계약이다. PRE artifact의 provider/local lane은 +absent/LEGACY fence를 test fixture SQL로 우회하지 않는다. isolated sandbox에서 exact PRE +artifact를 legacy-only/dark로 배포하고 audited batch `INITIALIZE_LEGACY`를 root-commit한다. +그 뒤 canonical-only instances를 같은 PRE artifact의 `CUTOVER_WAIT`로 올리고 route별 +`BEGIN_DRAIN`에서 trusted external issuer의 complete old-node inventory signed header와 row set을 +동결한다. old-node 0도 header 한 건을 요구한다. expired ACTIVE permit은 별도 authenticated +bounded terminalizer를 root-commit한 뒤 read-only snapshot으로 다시 확인하며 query/COMPLETE가 +암묵적으로 state를 바꾸지 않는다. + +PRE qualification ownership evidence는 다음 closed union이다. + +- `PRE_QUIESCENCE_EVIDENCE`: exact signed BEGIN inventory, selected signed quiescence attestation, + exact registry/permit/holder/node set, per-node deployment-generation tombstone와 legacy + credential/egress irreversible revocation, consumer inventory 0, provider-call ledger identity/ + snapshot/open-count 0, ACTIVE permit 0을 요구한다; +- `PRE_HARD_BOUND_EVIDENCE`: exact signed BEGIN inventory, all-hard-bound registry/evidence revision, + `RELEASED|EXPIRED_PROVEN` permit과 ACTIVE permit 0을 요구하며 selected/current-drain + attestation은 금지한다. + +두 branch가 모두 있거나 둘 다 없으면 qualification을 발급하지 않는다. exact +`ACTIVE/CANONICAL@g_final`을 관측한 뒤에만 provider probe를 보낸다. signed evidence의 acceptance +window 뒤에도 QUIESCENCE branch의 root-committed irreversible facts는 유효하지만, qualification +runner는 retained canonical payload/signature/trust snapshot을 Java로 다시 Ed25519 검증한다. + +FINAL qualification ownership evidence도 closed union이다. + +- `FINAL_FRESH`는 V8의 AWAITING state 뒤 external infrastructure issuer authorization으로 실행한 + `notificationFreshProvisioning`, retained signed provenance, `FRESH_PROVISIONED` + discriminator와 exact `INITIALIZE_CANONICAL_FRESH`/canonical fence set을 요구한다. signed + provenance에는 exact DB resource/birth certificate, 세 zero inventory, provider-ledger zero와 + issuer가 서명 전에 commit한 irreversible no-legacy-authority fence의 전체 retained field가 + 있어야 한다; +- `FINAL_UPGRADE`는 V8의 `UPGRADE_VALIDATED` discriminator, validated history digest와 exact + canonical upgrade fence/history set을 요구한다. + +둘 다 있거나 둘 다 없거나 반대 branch provenance/history가 섞이면 fail closed한다. production +lane은 production private key를 artifact/environment/DB에 두지 않은 external issuer만 +수락한다. deterministic local issuer의 `LOCAL_TEST` evidence는 production qualification을 +충족하지 못한다. qualification runner도 production과 같은 application read use case -> +retained-evidence query port -> persistence read-only adapter -> verifier port를 사용한다. FRESH는 +discriminator/provenance/init/fence, UPGRADE는 discriminator와 full +operation/registry/permit/inventory row와 selected·superseded·unselected를 포함한 모든 +attestation header/child를 bounded snapshot으로 읽고 Java payload/SPKI/trust 및 semantic exact +equality를 재검증한다. cleanup artifact에서 이 read seam이나 retained row가 빠지면 +qualification을 발급하지 않는다. + +FINAL은 transitional endpoint/class/permission surface 0, 정확히 migrator/runtime/provisioner +세 database role, migration-only migrator ownership과 runtime non-ownership을 증명한다. +runtime의 retained cutover write·cutover sequence·transitional function `EXECUTE`는 0이되 +operational-journal exact least-privilege DML/SELECT는 유지되어야 한다. provisioner는 exact +snapshot/read-lock과 apply 두 function `EXECUTE`만 가지며 generic +SELECT/DML/sequence/DDL/role-membership과 다른 function `EXECUTE`는 0이어야 한다. test는 Java +검증 뒤 expiry까지 pause하면 apply mutation 0, apply 성공 뒤 commit 지연은 irreversible +birth/fence 아래 안전하고 commit acknowledgement 전 success 0, issuer fence commit 전 +authorization 발급 0도 검증한다. enforcement activation/read-back보다 앞선 zero snapshot, +post-enforcement source revision/time이 없는 manifest와 fence seal 전 signing도 거부한다. +credential revoke 전부터 열린 legacy DB session/provider connection을 가진 paused client를 +resume해도 session/flow termination과 established-flow deny 때문에 DB/provider I/O가 0임을 +integration evidence로 남긴다. + +manifest는 writer route-set digest와 exact canonical generation-set digest 외에 위 PRE/FINAL +discriminator와 branch별 signed payload/history digest를 가진다. mixed marker, caller stage +override 또는 branch mismatch는 fail closed한다. abort로 `g_final` 또는 runtime +expected-generation profile이 바뀌면 기존 PRE evidence는 stale이며 같은 production semantics로 +sandbox cutover/qualification을 다시 수행한다. + +### 29.10 privacy/cardinality test + +- representative PII/secret marker를 log/span/metric scrape/exception/DB plaintext scan에서 검색; +- metric unique time-series upper bound; +- queue/backlog dump와 actuator/health payload redaction; +- Java `toString()`/assertion snapshot redaction; +- backup/export fixture에서 ciphertext/key reference만 확인; +- terminal retention/redaction과 dedupe tombstone 분리. + +### 29.11 proposed task/lane + +다음 이름은 구현 계획에서 생성할 conceptual target이며 현재 존재한다고 주장하지 않는다. + +```text +:application-core:test +:adapter:outbound:notification:test +:adapter:outbound:persistence-jpa:test +:adapter:inbound:web:test +:app-bootstrap:test + +notificationContractTest +notificationPostgresIntegrationTest +notificationSlackProtocolTest +notificationSesProtocolTest +notificationPrivacyTest +:app-bootstrap:notificationSlackReadiness +:app-bootstrap:notificationSesReadiness +notificationProductionReadiness +``` + +`notificationProductionReadiness`는 §17.1에서 release가 선택한 exact card ID set의 required +real-provider lane, persistence +concurrency, callback, privacy와 config composition evidence를 aggregate한다. + +### 29.12 evidence claim matrix + +| 주장 | 최소 evidence | +| --- | --- | +| local route/render behavior | unit/property/contract | +| durable append | same-DB transaction integration | +| concurrent single-owner claim | real PostgreSQL concurrency/fault | +| no blind retry after maybe-send | crash/response-loss state test | +| Slack inline/durable exact card R2 | sandbox real API + mode별 transaction/fault/protocol/config | +| SES durable SNS exact card R2 | sandbox real API + EmailTag/SNS feedback + IAM/config | +| zero-resource disabled | bootstrap bean/thread/client/probe assertions | +| PII-safe | log/metric/span/DB/retention scan | +| rolling revision compatibility | all live/retained revision migration/rollback test | +| production topology R3 | production-like scale/failure/rotation exercise | + +한 row의 evidence를 다른 provider, mode, region, workspace 또는 topology로 일반화하지 않는다. + +## 30. performance와 chaos qualification + +### 30.1 load profile + +적어도 다음 workload를 분리한다. + +- steady transactional email; +- burst security Slack alert; +- provider throttling 중 backlog; +- callback burst; +- retry/reconcile storm; +- large-but-valid template rendering; +- mixed critical/best-effort route. + +측정: + +- append p50/p95/p99와 business transaction 영향; +- eligible-to-first-attempt lag; +- provider attempt latency; +- claim/finalize DB TPS와 lock wait; +- encryption/render CPU/heap; +- backlog recovery rate; +- duplicate provider call evidence; +- callback apply lag; +- payload redaction lag. + +### 30.2 failure injection + +- process kill after claim; +- process kill immediately before/after `WIRE_AUTHORIZED` commit와 provider call; +- lease 만료 중 blocked provider call의 늦은 accepted response; +- provider accepted response 뒤 DB unavailable; +- DB commit success response loss to caller; +- key manager/secret manager unavailable; +- old template/key revision removed; +- Slack/SES auth revoked; +- provider 429/throttle/outage; +- malformed provider response; +- callback before accepted finalize; +- duplicate/out-of-order/corrupt callback; +- concurrent fallback finalizer가 next leg 하나만 활성화하는 경쟁; +- cancellation/expiry/suppression과 wire authorization 경쟁; +- SES response-loss 뒤 EmailTag + verified `SEND`로 accepted 복원; +- `DELIVERY_DELAY/DELIVERY/COMPLAINT` fact 모든 순열의 동일 projection; +- HMAC rotation 전 event/suppression의 rotation 후 replay; +- SNS 늦은 정상 retry와 위조 `SigningCertURL`; +- payload ciphertext redaction 뒤 receipt/suppression matching; +- clock skew; +- disk/DB capacity pressure; +- rolling deploy with old/new worker. + +각 fault 뒤 state가 terminal인지 retry/reconcile/manual인지와 duplicate risk를 증거로 남긴다. + +### 30.3 no exactly-once claim + +테스트에서 duplicate 0건이 관찰되어도 외부 provider와 DB 사이 exactly-once를 증명한 것이 +아니다. readiness card는 다음처럼 표현한다. + +```text +at-least-one durable intent record ++ bounded single-owner local attempt ++ provider/card-specific retry/reconciliation ++ explicit indeterminate state +``` + +provider native idempotency/reconciliation이 없으면 unknown window의 duplicate 또는 terminal +manual resolution risk를 runbook에 남긴다. + +## 31. Gradle dependency와 architecture + +### 31.1 notification leaf + +notification leaf가 유지할 project edge: + +```text +domain-core +application-core +shared-contract +adapter-outbound-support +``` + +추가할 수 있는 external library 후보: + +- AWS SDK for Java 2.x SES v2 module; +- Slack Java SDK Web API client 또는 같은 leaf 안의 bounded provider-local HTTP engine; +- template/rendering library가 필요하면 sandboxable, bounded, reflection-off evidence가 있는 + 최소 모듈; +- provider response JSON/HTTP dependencies는 leaf 내부 implementation detail. + +초기 구현 계획에서 Slack은 공식 +[Java Slack SDK](https://docs.slack.dev/tools/java-slack-sdk/)의 Web API client를 우선 +평가한다. timeout, proxy, TLS, retry, connection lifecycle과 actual attempt count를 통제하지 +못하면 provider-local bounded client로 바꾸며, generic `adapter-outbound-httpclient`를 몰래 +의존하지 않는다. + +모든 external dependency는 lockfile, license, CVE, transitive HTTP/logging conflict와 Java +21/Spring Boot 4 호환을 검증한다. + +### 31.2 persistence-jpa + +notification table/migration와 store adapter는 persistence-jpa leaf에 추가한다. + +- persistence entity/repository가 application이나 notification leaf로 나가지 않는다; +- application port를 구현한다; +- application-owned `NotificationFinalizationRetainedEvidenceQueryPort`를 구현하는 read-only + adapter가 FRESH/UPGRADE branch의 full bounded child row를 한 consistent snapshot으로 읽고 + persistence entity가 아닌 immutable application projection을 반환한다; +- `TransactionPort.inRootWrite`는 ambient actual transaction을 거부하고 physical commit 뒤 + 반환한다. 기존 join-capable `inWrite`와 의미를 섞거나 `NEVER` propagation을 추가하지 않는다; +- PostgreSQL-specific claim SQL은 adapter 내부다; +- encryption abstraction의 key material은 persistence entity에 노출하지 않는다; +- schema migration/rollback/retention index를 같은 owner leaf가 검증한다. + +### 31.3 inbound web + +callback controller/verifier는 inbound web leaf에 두고 application receipt use case만 호출한다. +notification outbound adapter의 internal provider type에 의존하지 않는다. provider-specific +signature code가 application DTO로 유출되지 않게 inbound internal collaborator로 둔다. +SES R2 ingress는 SNS SignatureVersion 2 verifier와 provider-neutral +`NotificationReceiptIngressDescriptor`를 제공한다. send-side notification adapter를 직접 +호출하거나 그 settings class를 import하지 않는다. + +### 31.4 app-bootstrap + +bootstrap은 다음만 조합한다. + +- canonical settings에서 파생한 notification-local send profile, inbound receipt profile과 + persistence profile; +- adapter별 capability descriptor와 application pure compatibility validator; +- current `SecretSource`를 adapter-owned credential/key material factory에 연결하는 bridge; +- application dispatch/receipt use case; +- FINAL retained-evidence read use case와 query/verifier port implementation binding; +- notification provider port implementation; +- persistence store implementation; +- scheduler/executor/lifecycle/readiness. + +retry/fallback/consent/state policy 자체를 `@Configuration`이나 settings class에 구현하지 않는다. +bootstrap composition이 `ApplicationContext`/bean reflection으로 sibling capability를 추론하지 +않으며, secret/key material을 settings/application value에 보관하지 않는다. bootstrap +readiness는 위 application read use case만 호출하고 repository, persistence entity, JDBC, +query adapter나 verifier 구현을 직접 호출하지 않는다. + +### 31.5 registry 변경 조건 + +다음 요구가 생기면 `modules.json`, `settings.gradle`, Gradle dependency gate, architecture test와 +문서를 함께 변경하는 별도 architecture decision이 필요하다. + +- notification leaf가 generic HTTP client capability를 의존; +- broker consumer/inbox를 위한 inbound messaging leaf; +- provider callback 전용 inbound notification leaf; +- notification persistence를 독립 leaf로 분리; +- 별도 notification service/deployment. + +현재 19-leaf 경계를 우회해 app-bootstrap에 consumer/business handler를 넣지 않는다. + +## 32. 단계별 migration + +### Phase 0 — truth와 activation drift 정리 + +- 현재 implementation/evidence 표 확정; +- README/CLAUDE/YAML/env registry/conditional test의 selector drift inventory; +- canonical config와 migration alias 결정; +- 기존 `slack-webhook`, `google-email`, `NotificationPort`를 R0 legacy로 명시; +- production consumer 0과 fake-only evidence 명시; +- design 승인 전 behavior 변경 없음. + +Acceptance: + +- 한 문서에서 current truth를 재현할 수 있다; +- legacy/canonical key의 removal/cutover rule이 정해진다; +- R0를 R2로 오해하는 문구가 없다. + +### Phase 1 — application semantic foundation + +- bounded identity/value; +- feature-specific application request factory/policy와 outbound port pattern; +- typed parameter/recipient와 redacted value; +- intent/mode/policy/result; +- synchronous best-effort용 `TransactionPort.inRootWrite` boundary; +- provider-neutral plan/append/store/attempt/receipt ports; +- state/outcome/fingerprint contract; +- application contract/unit test. + +Acceptance: + +- core에 Spring/JPA/SDK/transport/raw DTO가 없다; +- critical vs best-effort가 type/catalog로 구분된다; +- best-effort는 physical commit 뒤에만 send하고 ambient transaction에서는 side effect 전에 + fail-fast한다; +- indeterminate/fallback/state transition test가 있다. + +### Phase 2 — catalog, template와 canonical activation + +- code catalog/route compiler; +- immutable template manifest/assets; +- typed renderer/locale/escaping/limits; +- canonical settings/expected-state/expected-binding-ids; +- adapter별 descriptor와 application compatibility validator; +- zero-resource binding; +- startup/readiness graph; +- legacy conflict fail-fast. + +Acceptance: + +- binding graph가 exact tuple로 compile된다; +- no binding resources 0; +- template drift/injection/locale test; +- existing legacy는 아직 별도 path로만 동작한다. + +### Phase 3 — Slack Web API best-effort reference + +- `slack-web-api` provider/client; +- one physical attempt/outcome mapping; +- per-channel rate/admission; +- `BEST_EFFORT_INLINE`; +- app-bootstrap sandbox readiness lane; +- incoming webhook legacy capability descriptor. + +Acceptance: + +- `chat.postMessage` response `(channel, ts)`와 exact state; +- timeout/response loss indeterminate; +- no documented idempotency를 readiness/runbook에 반영; +- no PII/secret telemetry. + +이 단계는 durable Notification R2 완료가 아니다. + +### Phase 4 — durable PostgreSQL workflow와 SES submission + +- intent/delivery/attempt/receipt schema; +- same-transaction append; +- claim token/attempt execution token/`WIRE_AUTHORIZED`/terminal-once result; +- shared admission gate park/resume generation; +- versioned direct AEAD/HMAC rotation/retention; +- dispatcher/retry/reconcile protocol; +- Slack Web API durable-single binding과 response-loss terminal unknown; +- SES v2 one-recipient send와 EmailTag attempt correlation; +- account/quota/IAM/readiness; +- concurrency/crash/finalize failure integration test. + +Acceptance: + +- same DB append atomicity; +- stale owner 차단; +- multi-instance park/restart/resume에서 binding fault backlog 보존; +- provider call transaction 밖; +- maybe-send crash -> indeterminate; +- Slack durable response-loss를 blind retry하지 않음; +- SES MessageId는 provider accepted로만 표시; +- selected submission cards의 real-provider evidence. + +### Phase 5 — feedback, suppression과 operational R2 + +- verified SES -> SNS HTTPS feedback callback와 DLQ; +- duplicate/orphan/out-of-order receipt; +- technical suppression; +- backlog/reconcile/redaction health; +- runbook/alerts/dashboards; +- rolling revision/key/credential test; +- `notificationProductionReadiness`. + +Acceptance: + +- selected Slack/SES cards의 required lanes no-skip; +- feedback authenticity/dedupe/race evidence; +- privacy/cardinality/retention evidence; +- rollback/rotation/failure drill; +- blocker/high architecture review 0. + +### Phase 6 — legacy removal과 optional provider + +- route별 retained signed old-node inventory, irreversible quiescence와 PRE closed-union cutover + evidence; +- V8 AWAITING/UPGRADE discriminator, post-migration irreversible no-legacy-authority fence가 + 결합된 signed DB-birth provenance, retained read seam과 FINAL closed-union evidence; +- legacy `NotificationPort`, global fail-open wrapper와 stale config 제거; +- `google-email` 제거 또는 exact Gmail card로 rename/rebuild; +- incoming webhook/Gmail/SMTP/SES stored template optional cards; +- 필요 시 inbound messaging/notification leaf architecture migration. + +Acceptance: + +- dual-running duplicate path 없음; +- env/sample/docs/test가 canonical graph 하나만 사용; +- optional provider가 baseline 보장을 자동 상속하지 않음. + +## 33. rollout와 cutover + +### 33.1 schema/code/config 순서 + +```text +expand schema + -> new code dark/disabled + -> contract and readiness evidence + -> route-specific canonical binding + -> old path disabled for that route + -> observation window + -> legacy config/code contract +``` + +old notifier와 new durable dispatcher가 같은 business event에 동시에 send하지 않게 route별 +single-writer cutover token/config revision을 둔다. 구현은 §16.7.1의 PostgreSQL fence/permit과 +`BEGIN_DRAIN -> bounded external poll -> COMPLETE_SWITCH`를 사용한다. 각 node config에는 legacy와 +canonical key를 동시에 넣지 않고, rolling node 간 config revision 차이는 shared owner/generation이 +fail-closed로 중재한다. legacy transport hard deadline이 증명되지 않으면 TTL expiry로 drain을 +추론하지 않고 BEGIN에서 동결한 complete old-node inventory, signed quiesce/consumer/ledger-0 +evidence, per-node deployment-generation tombstone와 legacy credential/egress irreversible +revocation, ACTIVE permit 0가 모두 준비될 때까지 switch를 중단한다. signed evidence는 수락 +시점의 bounded window 안에서 Java가 검증하고 root-commit하며, 이후 COMPLETE는 retained +signature와 immutable facts를 재검증한다. + +canonical-only PRE node는 persisted route set이 exact catalog이고 각 route가 configured target의 +LEGACY/DRAINING predecessor 또는 CANONICAL target인 경우에만 `CUTOVER_WAIT`로 liveness-healthy +기동할 수 있다. predecessor/DRAINING route의 admission/worker/provider call은 0이며, committed +CANONICAL target을 fresh read한 route만 열린다. route별 전환 중 mixed set은 이 closed state +범위에서 허용된다. FINAL artifact는 wait branch를 제거하고 all-canonical exact target만 +허용한다. + +첫 bridge release는 V7이나 startup에서 legacy ownership을 자동 생성하지 않는다. 인증된 +least-privilege operator가 canonical key catalog + PRE cutover decorator와 exact equality인 전체 route/generation +set/reason/token으로 §16.7.1의 audited batch `INITIALIZE_LEGACY`를 root-commit한 뒤에만 bridge +admission을 연다. 이 초기화 전에 fence가 없거나 set이 partial이면 legacy와 canonical admission은 +모두 fail closed한다. abort가 있으면 최종 canonical generation은 단순 `g+1`이 아니라 latest +committed `COMPLETE_SWITCH` operation result와 일치하는 route별 `g_final`이다. runtime expected +generation/config와 qualification manifest는 이 값을 exact axis로 가지며 변경 시 재검증한다. +final cleanup은 empty-store만으로 fresh를 추론하지 않는다. V8은 complete upgrade history를 +`UPGRADE_VALIDATED`로 보존하거나 notification state가 완전히 빈 database를 +`AWAITING_SIGNED_FRESH_PROVISIONING`으로 남긴다. any nonempty missing-fence/partial-history +database는 중단한다. fresh database는 V8 뒤 별도 `notificationFreshProvisioning` job이 +independent infrastructure issuer가 exact DB resource/birth certificate에 irreversible +no-legacy-authority fence를 먼저 commit한 뒤 서명한 environment/DB-system/database/schema/ +artifact/route-set/application-workload-0/business-consumer-0/legacy-node-0/provider-ledger-0 +authorization을 받는다. 같은 provisioner transaction에서 exact snapshot/read-lock function, +Java verifier, exact apply function 순서와 fresh apply-time window check를 통과한 후에만 retained +provenance, reviewed canonical route set 전체의 `INITIALIZE_CANONICAL_FRESH`와 +`FRESH_PROVISIONED` discriminator를 만든다. AWAITING 동안 normal runtime/readiness와 provider +I/O는 0이고 upgrade database의 complete operation history와 route별 `g_final` fence 및 FINAL +startup/readiness read seam은 cleanup 뒤에도 보존한다. + +### 33.2 backfill + +과거 generic outbox event나 log를 새 notification intent로 자동 backfill하지 않는다. 이미 +provider side effect가 있었는지 알 수 없기 때문이다. + +backfill이 필요하면: + +- 대상 event type/window를 명시; +- prior-send evidence와 duplicate tolerance를 검토; +- one-time migration occurrence ID; +- dry inventory와 승인; +- bounded batch; +- 별도 audit/rollback; +- active path와 dedupe collision test. + +### 33.3 rollback + +new route를 rollback한다고 legacy path로 자동 resend하지 않는다. 이미 provider accepted 또는 +indeterminate intent가 있을 수 있다. + +- `BEGIN_DRAIN` 뒤 `COMPLETE_SWITCH` 전에는 `ABORT_DRAIN`만 허용하며 새 LEGACY generation을 + 발급한다; +- `COMPLETE_SWITCH` 뒤에는 이 설계가 reverse handoff를 지원하지 않는다. legacy re-enable, + CANONICAL→LEGACY owner CAS와 old-code worker 재개를 금지한다; +- 이상 징후가 있으면 canonical 신규 append admission과 worker를 shared gate로 pause; +- in-flight/new states가 더 진행되지 않음을 확인; +- plan/template/key revision 유지; +- exact route별 pending/accepted/indeterminate inventory; +- 자동 resend 없이 manual reconciliation 또는 forward-fix 결정. + +COMPLETE 뒤 reverse가 필요하다면 canonical in-flight/intent backlog/provider-result를 drain하는 +별도 state machine, permit/evidence protocol, duplicate policy와 provider requalification을 먼저 +설계·승인해야 한다. duplicate risk 승인만으로 legacy를 다시 열 수 없다. + +## 34. completion criteria + +### 34.1 design 완료 + +- [x] current code/config/evidence diagnosis +- [x] alternatives와 selected architecture +- [x] module ownership/dependency direction +- [x] application contract와 business/technical policy boundary +- [x] mode/routing/template/provider outcome +- [x] durable state/store/claim/reconciliation +- [x] Slack/SES reference provider semantics +- [x] config/security/observability/health/lifecycle +- [x] test/evidence/migration/completion criteria +- [x] independent architecture/consistency/durability design review blocker/high 0 +- [x] 사용자 설계 승인 +- [x] 승인 후 implementation plan 작성 + +이 문서의 상태는 “상세 설계 승인, 구현 계획 작성, 구현 미착수”다. + +### 34.2 minimum implementation R2 + +- [ ] canonical binding/expected-state와 legacy conflict 제거 +- [ ] feature-specific application semantic port +- [ ] typed/frozen intent/template/route plan +- [ ] best-effort와 durable path 분리 +- [ ] same-DB intent/delivery/attempt/receipt journal +- [ ] owner token/version claim/finalize +- [ ] encrypted PII + keyed HMAC + retention +- [ ] indeterminate/reconcile/fallback safety +- [ ] Slack Web API exact provider card +- [ ] SES v2 submission/feedback exact provider card +- [ ] callback verification/dedupe/orphan race +- [ ] zero-resource disabled +- [ ] bounded deadlines/concurrency/amplification +- [ ] health/metrics/traces/runbook +- [ ] focused/architecture/real-provider/no-skip/privacy evidence +- [ ] independent review blocker/high 0 +- [ ] LLM Wiki capture + +### 34.3 금지할 완료 표현 + +- fake test만으로 “Slack/Email 연동 완료”; +- MessageId/ts만으로 “사용자에게 전달 완료”; +- global fail-open path를 “reliable notification”; +- local DB claim만으로 “exactly once delivery”; +- optional provider lane skip 상태로 “production ready”; +- 한 provider/account/region card로 notification module 전체 R2; +- config만 있고 실제 client/consumer가 없는데 “enabled”; +- branch-note/검증 없이 “설계/구현 완료”. + +## 35. 운영 runbook 최소 항목 + +### 35.1 backlog 증가 + +1. channel/provider/route/mode별 backlog와 oldest eligible age를 본다. +2. provider quota/auth/outage, DB claim/finalize, encryption/render failure를 분리한다. +3. concurrency를 무조건 높이기 전에 provider rate와 DB capacity를 확인한다. +4. expiry와 business urgency를 확인하되 consent/suppression을 우회하지 않는다. +5. pause/resume은 audited route use case로 수행한다. + +### 35.2 indeterminate 증가 + +1. attempt phase/provider/revision/deployment window를 분류한다. +2. blind retry/fallback을 켜지 않는다. +3. provider card의 reconciliation 가능 여부를 확인한다. +4. Slack은 `ts`가 없는 response-loss를 terminal unknown으로 두고, SES는 verified + EmailTag+`SEND` fact가 있을 때만 accepted로 복원한다. +5. duplicate risk와 business impact를 함께 보고 manual resolution한다. + +### 35.3 bounce/complaint 증가 + +1. verified feedback인지와 account/configuration set을 확인한다. +2. recipient/content를 log/export하지 않는다. +3. technical suppression 적용/충돌을 점검한다. +4. business consent/unsubscribe 시스템과 별도 incident로 연계한다. +5. sender identity/DKIM/DMARC/content/reputation과 provider account 상태를 조사한다. + +### 35.4 credential/key/template rotation + +1. new revision을 추가하고 startup/readiness를 통과한다. +2. new append/attempt가 새 revision을 쓰는지 확인한다. +3. old active/backlog/receipt/retention row를 inventory한다. +4. 모든 live/retained row가 참조하는 revision compatibility와 rollback을 검증한다. +5. old secret/key/template를 제거한 뒤 canary/failure alert를 확인한다. + +### 35.5 provider outage + +- liveness restart loop를 만들지 않는다; +- durable route는 bounded retry/backlog, best-effort route는 explicit outcome; +- auth/account/config fault는 shared admission gate를 park하고, readiness/config 재검증 뒤 audited + generation-bumping resume만 수행한다; +- unknown outcome과 definite rejection을 분리; +- cross-provider fallback은 authoritative failure일 때만; +- expiry/retention/capacity 임계치와 stakeholder communication을 실행한다. + +## 36. 승인 gate와 남은 설계 가정 + +다음 다섯 결정을 이 설계의 승인 gate로 둔다. + +1. 최소 R2 durability는 business state와 notification journal이 같은 PostgreSQL transaction에 + 참여할 수 있다는 가정을 채택한다. +2. 최소 R2는 intent 하나에 logical recipient 정확히 한 명이며, delivery row는 provider leg다. +3. Slack reference는 `chat.postMessage`, email reference는 Amazon SES v2로 채택하고 §17.1의 + exact 세 card만 초기 qualification 대상으로 둔다. +4. SES feedback은 `configuration set -> SNS HTTPS adapter-inbound-web + DLQ` topology로 고정한다. +5. 기존 `slack-webhook`, `google-email`, raw `NotificationPort`는 R0 legacy best-effort로 + 분류하고 route별 migration 뒤 제거한다. + +이 가정 중 1번이 실제 제품 topology와 다르면 구현 계획을 쓰기 전에 broker handoff + +inbound messaging/inbox architecture로 설계를 수정해야 한다. + +사용자는 2026-07-28에 위 다섯 결정을 승인했다. 구현은 +[Notification Production Capability Implementation Plan](../plans/2026-07-28-notification-production-capability.md)의 +작은 TDD task, owner leaf, registry-derived Gradle path, focused test, architecture gate, +real-provider evidence와 rollback point를 따른다. + +## 37. primary references + +### 37.1 repository + +- [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md) +- [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md) +- [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) +- `AGENTS.md` +- `src/config/architecture/modules.json` +- `src/adapter/outbound/notification/CLAUDE.md` +- `src/adapter/outbound/notification/README.md` + +### 37.2 Slack + +- [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage/) +- [Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) +- [Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/) +- [Web API response contract](https://docs.slack.dev/apis/web-api/) +- [conversations.history](https://docs.slack.dev/reference/methods/conversations.history/) +- [message event](https://docs.slack.dev/reference/events/message/) +- [chat.update](https://docs.slack.dev/reference/methods/chat.update/) +- [chat.delete](https://docs.slack.dev/reference/methods/chat.delete/) +- [Slack OAuth installation](https://docs.slack.dev/authentication/installing-with-oauth/) +- [Slack token rotation](https://docs.slack.dev/authentication/using-token-rotation/) +- [Slack developer sandboxes](https://docs.slack.dev/tools/developer-sandboxes/) +- [Java Slack SDK](https://docs.slack.dev/tools/java-slack-sdk/) +- [Slack `auth.test`](https://docs.slack.dev/reference/methods/auth.test/) + +### 37.3 Amazon SES and AWS SDK + +- [SES v2 SendEmail](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) +- [SES email sending process](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html) +- [SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html) +- [SES GetAccount](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetAccount.html) +- [Managing SES sending quota errors](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html) +- [SES EventDestination](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_EventDestination.html) +- [SES message insights](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetMessageInsights.html) +- [Monitoring SES activity using notifications](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html) +- [SES event publishing and message tags](https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html) +- [SES SNS event examples](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-examples.html) +- [SES sending authorization](https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html) +- [SES suppression list](https://docs.aws.amazon.com/ses/latest/dg/sending-email-global-suppression-list.html) +- [SNS signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html) +- [SNS HTTP retry policy](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html) +- [SNS HTTP subscription confirmation](https://docs.aws.amazon.com/sns/latest/dg/http-subscription-confirmation-json.html) +- [SNS `ConfirmSubscription`](https://docs.aws.amazon.com/sns/latest/api/API_ConfirmSubscription.html) +- [AWS SDK v2 default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html) +- [AWS SDK v2 retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html) + +### 37.4 Gmail and SMTP references for future cards + +- [Gmail users.messages.send](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) +- [Gmail API quotas](https://developers.google.com/workspace/gmail/api/reference/quota) +- [Gmail API sending](https://developers.google.com/workspace/gmail/api/guides/sending) +- [OAuth service accounts/domain-wide delegation](https://developers.google.com/identity/protocols/oauth2/service-account) +- [Gmail push notifications](https://developers.google.com/workspace/gmail/api/guides/push) +- [RFC 5321 — SMTP](https://www.rfc-editor.org/rfc/rfc5321) +- [RFC 3461 — SMTP DSN](https://www.rfc-editor.org/rfc/rfc3461) +- [RFC 4954 — SMTP AUTH](https://www.rfc-editor.org/rfc/rfc4954) +- [RFC 8314 — TLS for email submission/access](https://www.rfc-editor.org/rfc/rfc8314) diff --git a/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md b/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md new file mode 100644 index 0000000..76e199f --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-objectstorage-production-capability-design.md @@ -0,0 +1,4138 @@ +# Object Storage Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: 상세 설계 및 구현 계획 완료, 구현 미착수, R2 미구현 +- 독립 재리뷰: 완료 — 아키텍처 및 문서 실행성 blocker 0, high 0 +- LLM Wiki capture: 정본 vault + `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` 부재로 차단; 비정본 clone 대체 사용 안 함 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-objectstorage` +- 현재 구현 수준: whole-object `byte[]` 기반 local filesystem/S3-MinIO 예제, R0~R1 일부 +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- 구현 계획: + [Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md) +- 참고 설계: + [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md), + [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md), + [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) + +## 0. 구현 상태 + +2026-07-28 기준 구현된 범위는 다음뿐이다. + +- `application-core`의 범용 `ObjectStoragePort`; +- caller가 지정한 raw key에 `byte[]`를 put/get/delete/exists하는 계약; +- local filesystem adapter; +- synchronous AWS SDK v2 `S3Client`를 사용하는 S3/MinIO adapter; +- 설정 한 개로 filesystem 또는 S3 backend를 선택하는 Spring composition; +- filesystem unit test, mocked S3 unit test, Testcontainers MinIO integration test; +- module registry가 허용한 `application-core`, `shared-contract` 의존성. + +아직 구현되지 않은 범위: + +- streaming upload/download와 range read; +- immutable object reference와 version token; +- stable operation ID, request fingerprint, durable operation record; +- conditional create/update/delete와 unknown-outcome reconciliation; +- checksum 생성·전송·검증 계약; +- metadata/head 계약; +- staged upload, quarantine, scan, publication; +- presigned upload/download; +- multipart start/part/complete/abort/recovery; +- lifecycle, versioning, retention, legal hold 검증; +- production credential, encryption, TLS, expected-owner 정책; +- bounded timeout, connection pool, retry amplification, graceful shutdown; +- provider capability qualification과 exact readiness card; +- orphan/abandoned multipart/retired object reaper; +- database와 object storage 사이의 crash-safe workflow; +- 운영 metric, trace, audit, runbook; +- AWS sandbox fault/security evidence. + +따라서 현재 MinIO round-trip test가 통과하더라도 S3 production readiness를 의미하지 않는다. +이 문서의 상태가 “상세 설계 완료”인 것은 구현, R2 qualification 또는 운영 준비 완료를 +뜻하지 않는다. + +## 1. 설계 판정 + +현재 구현은 개발 편의를 위한 blob CRUD 예제다. + +```text +MultipartFile.getBytes() + -> UploadPosterImageCommand(byte[]) + -> DB write transaction 안에서 ObjectStoragePort.put(raw key, byte[]) + -> provider final object overwrite + -> aggregate에 raw key 저장 + -> file:// 또는 s3:// 내부 locator를 HTTP 응답으로 반환 +``` + +목표는 bucket/key CRUD wrapper가 아니다. 목표는 다음 capability다. + +> 제한된 크기의 content를 bounded streaming으로 저장·검증하고, private immutable +> reference를 통해 공개 상태와 version을 추적하며, direct transfer와 multipart의 +> 불확정 결과까지 재조정할 수 있는 production object publication capability + +선택한 핵심 구조는 다음과 같다. + +1. Application은 bucket, path, endpoint가 아닌 `ObjectDestinationId`를 선택한다. +2. 모든 mutation은 안정적인 `ObjectOperationId`와 canonical request fingerprint를 가진다. +3. caller는 raw provider key를 만들지 않는다. Adapter가 managed namespace 아래 immutable key를 + 생성한다. +4. Application과 domain은 provider locator 대신 opaque `ObjectReference`를 저장한다. +5. upload/download는 동기식 bounded chunk callback을 baseline으로 하며 adapter가 resource + lifecycle을 소유한다. +6. 업로드 완료, integrity 검증, malware scan, business attachment, public visibility를 서로 다른 + 상태로 모델링한다. +7. publication은 public ACL 변경이 아니라 private object에 대한 durable reference/manifest의 + 상태 전이로 구현한다. +8. provider 응답 유실은 임의 retry가 아니라 `INDETERMINATE`로 분류하고 먼저 reconcile한다. +9. ETag를 whole-object checksum 또는 portable version으로 간주하지 않는다. +10. database와 object storage 사이의 원자적 transaction을 주장하지 않는다. +11. provider 지원 수준은 설정 이름이 아니라 startup qualification과 CI evidence로 판정한다. +12. 사용하지 않는 provider는 client, thread, scheduler, directory 또는 network side effect를 + 만들지 않는다. + +## 2. 상위 설계 및 기존 capability 설계와의 관계 + +상위 설계의 §13.3은 다음 cross-capability baseline을 이미 정했다. + +- streaming, head, range; +- checksum과 conditional mutation; +- bounded presigned transfer와 multipart cleanup; +- encryption, TLS, endpoint, region, credentials; +- lifecycle, versioning, retention; +- quarantine와 scan 전 publication 금지; +- payload/metadata limit; +- explicit staged lifecycle; +- database rollback이 object write를 되돌린다고 가정하지 않기. + +이번 문서는 그 방향을 구현 계획으로 변환할 수 있도록 다음 결정을 추가한다. + +- 정확한 port 분리와 framework-free callback signature; +- logical destination, operation, object, reference, version의 identity; +- immutable data key와 mutable control record의 경계; +- state machine, request fingerprint, result taxonomy; +- managed upload, direct upload, multipart, download의 protocol; +- provider-neutral 보장과 AWS/MinIO/filesystem별 차이; +- provider capability descriptor와 startup qualification; +- presigned URL의 bearer-secret 취급 및 검증 후 publish; +- database attachment workflow의 commit 순서와 crash gap; +- version-aware delete, retention hold, orphan cleanup; +- exact readiness card와 R0/R1/R2/R3 증거; +- 기존 `ObjectStoragePort`와 sample-portfolio migration; +- idempotency response offload 계약과 sibling adapter boundary. + +기존 심화 설계에서 재사용하는 방식은 다음과 같다. + +| 기존 설계 | 재사용하는 결정 | Object Storage에서 달라지는 점 | +| --- | --- | --- | +| Redis | exact activation, provider/card readiness, fail-closed binding | data plane이 대용량 stream이며 object mutation의 unknown outcome을 별도로 다룬다. | +| Fileserver | stable operation ID, staged publication, immutable naming, reconciliation | rename 대신 object+manifest/reference 전이를 사용하고 multipart/presign이 추가된다. | +| HTTP Client | body lifecycle, timeout budget, retry amplification, secret-safe observability | provider SDK retry와 multipart part 단위 resource budget을 함께 제한한다. | + +이 문서와 상위 문서가 충돌하면 Object Storage 구현 세부에는 이 문서를 적용한다. 모듈 +dependency edge에는 언제나 `src/config/architecture/modules.json`이 우선한다. + +## 3. 현재 코드의 증거 기반 진단 + +### 3.1 Application contract + +| 영역 | 현재 구현 | 운영상 의미 | +| --- | --- | --- | +| Identity | caller supplied `String key` | tenant, destination, ownership, generation 경계가 없다. | +| Upload | `put(String, byte[], String)` | content 전체를 heap에 적재하고 overwrite한다. | +| Download | `Optional get(String)` | large object와 range/backpressure를 지원하지 않는다. | +| Existence | `boolean exists(String)` | not-found와 forbidden/provider failure를 충분히 구분하지 못한다. | +| Delete | unconditional `void delete(String)` | version, precondition, retention hold, unknown outcome이 없다. | +| Receipt | key, size, content type, provider `URI` | `file://`와 `s3://bucket/key`가 application/HTTP로 유출된다. | +| Error | primitive/SDK exception 혼합 | retry와 reconciliation 결정을 application이 할 수 없다. | + +### 3.2 Filesystem adapter + +현재 `FilesystemObjectStorageAdapter`는 다음 문제를 가진다. + +- constructor에서 base directory를 즉시 생성한다; +- `Files.write(final, bytes)`로 기존 파일을 truncate/overwrite한다; +- temp, exclusive create, file force, directory force가 없다; +- `readAllBytes`로 전체 content를 heap에 적재한다; +- content type과 checksum을 영속화하지 않는다; +- `normalize().startsWith(baseDir)` lexical check만 사용한다; +- nested symlink와 TOCTOU 탈출을 막지 못한다; +- `"."`, `"a/.."`처럼 root alias로 normalize되는 key가 가능하다; +- root alias delete가 base directory 자체를 대상으로 삼을 수 있다; +- file permission, quota, inode, retention, orphan cleanup이 없다; +- absolute `file://` URI를 receipt에 넣는다. + +따라서 이 provider는 현재 local developer fixture이지 persistent production store가 아니다. + +### 3.3 S3/MinIO adapter + +현재 `S3ObjectStorageAdapter`는 다음 문제를 가진다. + +- synchronous `S3Client`와 `RequestBody.fromBytes`/`getObjectAsBytes`만 사용한다; +- put이 unconditional overwrite다; +- checksum, size precondition, metadata schema가 없다; +- exact version/ETag을 receipt에 보존하지 않는다; +- conditional create/delete와 reconciliation이 없다; +- presign과 multipart가 없다; +- SSE/KMS, expected bucket owner, ownership/BPA qualification이 없다; +- finite API call/attempt/acquire/read/write timeout을 명시하지 않는다; +- provider SDK exception이 application boundary를 통과할 수 있다; +- missing object mapping이 operation마다 일관되지 않다; +- `s3://bucket/key` URI 생성이 성공한 write 뒤에 별도로 실패할 수 있다; +- startup `HEAD bucket` 실패가 404이면 runtime identity로 bucket을 생성할 수 있다. + +같은 adapter가 endpoint override와 path-style 설정만으로 AWS S3와 MinIO를 “동일한 backend”로 +취급한다. 이는 protocol happy path 재사용에는 유용하지만 consistency, checksum, conditional +request, versioning, retention, encryption, error 의미론의 동등성을 증명하지 않는다. + +### 3.4 Settings와 activation + +현재 설정은 다음 production-unsafe default를 가진다. + +```yaml +backend: filesystem +base-path: ./.data/objectstorage +bucket: ca-skeleton +endpoint: http://localhost:9000 +region: us-east-1 +path-style-access: true +auto-create-bucket: true +``` + +- filesystem bean이 `matchIfMissing=true`로 활성화된다; +- S3 설정이 없으면 local plaintext MinIO를 가리킨다; +- access key 하나만 있으면 secret key pair validation 없이 static provider를 만든다; +- production bucket을 runtime startup에서 생성할 수 있다; +- `app-bootstrap`은 registry상 objectstorage leaf에 의존하지 않지만 sample application의 넓은 + component scan에서는 configuration이 발견된다; +- Docker runtime의 read-only root와 `./.data/objectstorage` default가 맞지 않는다; +- canonical env/settings/secrets registries에 object storage key가 등록되지 않았다. + +### 3.5 Sample workflow + +`sample-portfolio`의 image upload는 다음 failure gap을 가진다. + +```text +begin DB transaction + load Poster + overwrite deterministic final object key + attach raw key + save Poster + enqueue outbox +commit DB transaction +``` + +- DB rollback이 이미 완료된 object overwrite를 복원하지 못한다; +- 기존 image를 overwrite했으므로 compensation으로 delete해도 이전 version을 복구하지 못한다; +- upload 시간 동안 DB transaction과 connection을 점유한다; +- concurrent transaction loser도 final object를 바꿀 수 있다; +- `MultipartFile.getBytes()`가 inbound와 application 양쪽에서 전체 heap materialization을 만든다; +- client가 보낸 content type을 검증 없이 신뢰한다; +- delete use case는 Poster만 삭제하고 object lifecycle을 처리하지 않는다; +- aggregate와 public DTO에 raw key가 저장/노출된다; +- scan, checksum, quarantine, ready 상태가 없다. + +### 3.6 별도 persistence idempotency blob seam + +`adapter:outbound:persistence-jpa`에는 idempotency response body를 외부 object store로 offload할 +수 있다는 별도 interface가 있다. Object Storage leaf가 그 sibling adapter interface를 +구현하면 registry edge와 Clean Architecture 방향을 위반한다. + +이번 baseline에서 이 용도는 제외한다. 향후 필요하면: + +1. application 의미인 response-blob 계약을 `application-core`에 둔다; +2. persistence adapter와 objectstorage adapter가 각각 application port만 의존한다; +3. orchestration은 application service 또는 composition root가 담당한다; +4. objectstorage가 persistence leaf의 type을 import하지 않는다. + +## 4. 범위와 명시적 비범위 + +### 4.1 전체 설계 범위 + +- bounded streaming managed upload/download; +- single contiguous range read; +- metadata/head와 exact version; +- SHA-256 content digest와 provider transport checksum; +- immutable create와 conditional mutation; +- private staged object와 durable publish reference; +- server-mediated transfer; +- short-lived presigned PUT/GET; +- adapter-owned managed multipart와 optional direct multipart; +- quarantine/scan integration seam; +- version-aware retirement/delete; +- orphan, abandoned multipart, indeterminate operation reconciliation; +- filesystem-local-dev, AWS S3 general-purpose, version-pinned MinIO provider cards; +- TLS, credentials, ownership, encryption, endpoint, lifecycle startup validation; +- resource budgets, timeout, retry, graceful shutdown; +- readiness, metrics, trace, audit, CI evidence; +- sample image attachment migration protocol. + +`filesystem-local-dev`는 이 전체 설계의 R0/R1 개발 provider일 뿐 R2 대상이 아니다. R2 claim은 +exact AWS S3 또는 qualified MinIO/deployment provider와 §32의 개별 card 조합에만 부여한다. + +### 4.2 Optional capability + +다음은 destination이 요구하고 provider card가 증명할 때만 활성화한다. + +- browser POST policy; +- SSE-KMS 또는 DSSE-KMS; +- Object Lock retention/legal hold; +- provider-side copy; +- provider checksum algorithm 추가; +- filesystem-local-persistent; +- direct multipart upload; +- customer-managed public download domain; +- provider notification을 이용한 reconciliation hint; +- cross-region replication을 고려한 secondary verification. + +Optional capability 부재를 silent emulation하지 않는다. 요청 시 +`UNSUPPORTED_CAPABILITY`로 실패한다. + +### 4.3 비범위 + +- generic bucket CRUD/list console; +- runtime bucket 생성, lifecycle/IAM/KMS/IaC provisioning; +- public-read ACL 또는 website hosting; +- CDN/cache invalidation; +- generic file server 또는 mounted drop-zone; +- inbound object-created event consumer; +- scanner/AV engine 자체 구현; +- media transcoding, thumbnail 생성, EXIF business policy; +- arbitrary provider endpoint를 request마다 선택하는 기능; +- database와 object store의 distributed transaction 또는 exactly-once claim; +- backup/restore 시스템 자체; +- Glacier restore workflow; +- S3 Express One Zone/directory bucket; +- Multi-Region Access Point와 cross-region failover; +- provider replication을 application consistency로 일반화; +- unlimited multi-range response; +- S3 Select/object SQL; +- unbounded user-specified metadata/tag; +- raw object key를 public API로 제공하는 기능. + +## 5. HARD invariants + +다음 항목은 구현 선택이 아니라 위반 시 중단하는 불변식이다. + +1. `domain-core`에는 Spring, AWS SDK, `Path`, `URI`, stream, transport DTO를 넣지 않는다. +2. `application-core` port에는 AWS SDK request/response, `S3Client`, provider exception을 노출하지 + 않는다. +3. inbound `MultipartFile`, servlet stream, WebFlux publisher를 application command로 전달하지 + 않는다. +4. controller가 bucket/key를 만들거나 SDK/repository/object persistence entity를 호출하지 않는다. +5. caller가 provider-relative raw key, bucket, base path, endpoint를 선택하지 않는다. +6. Destination ID는 bounded registered value이며 untrusted inbound/tenant input이 임의 destination을 + 선택하지 않는다. +7. object reference는 locator도 authorization token도 아니다. +8. presigned URL은 bearer secret으로 취급하고 log, trace, metric, audit payload에 기록하지 않는다. +9. 모든 production mutation은 stable operation ID와 canonical fingerprint를 가진다. +10. R2 write fingerprint는 exact length + full content digest 또는 검증 가능한 immutable source + revision을 포함한다. +11. 같은 operation ID에 다른 fingerprint가 오면 conflict이며 기존 결과를 반환하지 않는다. +12. 기본 upload는 immutable create-only다. unconditional overwrite는 baseline에 없다. +13. successful SDK response와 business publication을 같은 상태로 간주하지 않는다. +14. ETag를 portable checksum 또는 multipart 전체 MD5로 간주하지 않는다. +15. partial/truncated upload·download 또는 consumer failure를 success receipt로 만들지 않는다. +16. content size, chunk size, part size/count/concurrency, metadata 수와 길이에 finite limit가 있다. +17. timeout/retry 후 mutation 결과가 확정되지 않으면 phase-specific `INDETERMINATE`이며 blind + retry하지 않는다. +18. database transaction 안에서 remote object transfer를 실행하고 rollback 원자성을 주장하지 + 않는다. +19. unpublished/quarantined object에 public/direct download grant를 발급하지 않는다. +20. scan verdict가 필요한 destination은 `SCAN_CLEAN` 전 publish하지 않는다. +21. object lifecycle/retention/hold를 application business 삭제로 우회하지 않는다. +22. versioned bucket의 delete marker 생성을 physical purge라고 보고하지 않는다. +23. cleanup은 owned namespace와 known schema만 처리하고 newer/unknown record를 삭제하지 않는다. +24. LIST 결과만으로 direct lookup, ownership, completion truth를 판정하지 않는다. +25. production startup은 bucket, policy, lifecycle, public access setting을 자동 수정하지 않는다. +26. disabled capability는 bean/client/thread/scheduler/network/directory side effect가 0이다. +27. filesystem과 S3-compatible provider가 AWS S3와 동등하다고 일반화하지 않는다. +28. module dependency는 registry의 `allowed_dependencies`만 따른다. +29. provider/card별 증거 없이 module 전체를 R2라고 부르지 않는다. +30. readiness는 exact required destination/provider/capability mismatch에서 fail closed한다. +31. metric/tag/log에 object key, bucket, tenant/user ID, original filename, content, URL을 넣지 않는다. +32. verification과 Wiki capture 또는 명시적 capture 차단 사유 없이 완료를 주장하지 않는다. + +## 6. 대안 검토 + +### A. 현재 CRUD port에 InputStream만 추가 + +기각한다. + +- caller와 adapter 사이 close ownership이 모호하다; +- repeatability와 retry 가능성을 표현하지 못한다; +- raw key/overwrite/locator leak이 남는다; +- direct upload, multipart, staged publish를 같은 의미로 섞게 된다. + +### B. 범용 `ObjectStoragePort` 하나에 모든 operation 추가 + +기각한다. + +- read-only use case도 delete/presign/multipart 권한을 가진 interface를 주입받는다; +- provider optional capability가 nullable method와 runtime branch로 퍼진다; +- 테스트와 readiness가 “module on/off” 한 단계로 뭉개진다. + +Operation family별 작은 port를 사용하고 destination capability compile 단계에서 조합한다. + +### C. Application에 AWS presigner와 multipart upload ID 노출 + +기각한다. + +- provider 교체와 테스트가 불가능해진다; +- provider upload ID와 part ETag가 domain state로 퍼진다; +- security/expiry/header 조건을 adapter 밖에서 조립하게 된다. + +Application은 opaque `DirectTransferSessionId`, `PartReceiptToken`, typed grant만 본다. + +### D. 업로드 후 final key로 copy하여 publish + +기본 전략으로 기각한다. + +- 대용량 object copy 비용과 latency가 추가된다; +- copy response loss의 unknown outcome이 하나 더 생긴다; +- KMS/metadata/version semantics가 복잡해진다. + +기본은 immutable private data object를 그대로 두고 durable reference/manifest만 publish한다. +보안 등급이나 destination이 physical namespace 분리를 요구할 때만 optional copy capability를 +사용한다. + +### E. object tag 또는 ACL 변경을 publication truth로 사용 + +기각한다. + +- tag/ACL policy와 cache/authorization coupling이 커진다; +- S3-compatible provider 의미가 일치하지 않는다; +- public access 실수의 blast radius가 크다. + +모든 bucket은 private이고, application authorization 후 server stream 또는 short-lived grant로 +전달한다. + +### F. DB transaction 안에서 upload하고 실패 시 delete + +기각한다. + +- delete도 실패하거나 indeterminate일 수 있다; +- overwrite된 기존 object는 복구되지 않는다; +- DB lock/connection 보유 시간이 content 크기에 비례한다. + +외부 transfer와 짧은 DB transaction을 단계별로 분리하고 durable recovery state를 둔다. + +### G. provider별 Gradle leaf 즉시 분리 + +초기에는 보류한다. + +현재 registry의 19개 leaf와 dependency direction을 유지하며 한 physical leaf 안에서 package, +settings, test suite, readiness card를 분리한다. 다음 조건 중 둘 이상이 생기면 별도 설계로 +leaf split을 검토한다. + +- provider별 독립 release cadence; +- SDK/security patch 주기가 현저히 다름; +- runtime image에서 선택하지 않은 SDK를 제거해야 함; +- credential/IAM 또는 deployment owner가 분리됨; +- provider별 test/CI 비용이 기본 pipeline을 과도하게 지연함; +- registry edge가 실제로 달라짐. + +### H. filesystem을 S3 emulator로 승격 + +기각한다. + +Filesystem은 local development와 provider-neutral contract 일부를 빠르게 검증하는 수단이다. +ETag, multipart, presign, versioning, retention, KMS를 흉내 내어 R2 증거로 사용하지 않는다. + +## 7. 목표 아키텍처 + +```text +adapter:inbound:web + - multipart parsing / request validation + - bounded ingress bridge + - authorization mapping + | + v +sample/application orchestration + - business consent and ownership + - attachment state + - DB transaction boundaries + | + v +application-core object storage ports + - publication / inspection / transfer / deletion / direct transfer + - framework-free IDs, request, receipt, error + | + v +adapter:outbound:objectstorage + +-- binding compiler / capability registry + +-- provider-neutral operation kernel + +-- durable control-plane codec/store + +-- reconciliation / cleanup engine + +-- filesystem-local-dev provider + +-- AWS S3 general-purpose provider + `-- version-pinned MinIO provider + | + v +private provider data plane + private control namespace +``` + +Inbound stream과 outbound storage stream을 직접 결합하지 않는다. Application callback 경계가 +다음 책임을 분리한다. + +- inbound adapter: transport framing, multipart limit, client disconnect; +- application: business authorization, destination 선택, operation identity; +- objectstorage adapter: chunk budget, hashing, SDK lifecycle, retry, stage/publish; +- provider: durable byte/object primitive. + +## 8. 모듈과 계층 소유권 + +### 8.1 `domain-core` + +- provider 독립적인 business entity와 invariant만 소유한다; +- object storage concept가 business에 필요하면 opaque string wrapper 수준의 domain reference만 + 둘 수 있다; +- checksum, bucket, key, presign, multipart, scan vendor result를 소유하지 않는다. + +### 8.2 `application-core` + +- outbound port와 framework-free value object; +- logical destination, operation ID, object reference, version token; +- content producer/consumer callback; +- common request/result/error taxonomy; +- application-level deadline와 cancellation signal; +- capability requirement. + +Business별 media type 허용, 사용자 소유권, 공개 승인, attachment 교체 정책은 sample 또는 실제 +application layer가 소유한다. 공통 adapter limit는 business policy의 대체물이 아니다. + +### 8.3 `adapter:outbound:objectstorage` + +- settings binding과 exact validation; +- provider selection과 effective capability compilation; +- internal key/reference/manifest codec; +- SDK/client/presigner lifecycle; +- streaming, hashing, conditional operation; +- operation journal과 reconciliation; +- cleanup/retention execution; +- provider error normalization; +- health/metric/trace/audit instrumentation. + +Malware clean/malicious 판정 정책을 adapter configuration이나 mapper에 넣지 않는다. + +### 8.4 `adapter:inbound:*` + +- HTTP body/multipart limit; +- upload command 생성; +- request content type/name을 untrusted input으로 다룸; +- callback을 통해 bounded chunk를 application에 전달; +- direct transfer grant를 안전한 response DTO로 매핑; +- raw reference 또는 URL logging 방지. + +### 8.5 `sample-portfolio` + +- image attachment business lifecycle; +- Poster ownership과 permission; +- 허용 media type/size/image decode 정책; +- pending/ready/retired attachment 상태; +- DB transaction/outbox orchestration; +- sample consumer test. + +Production leaf가 sample type에 의존하지 않는다. + +### 8.6 `app-bootstrap` + +- 실제 runtime에서 objectstorage leaf를 classpath에 넣을지 결정; +- required destination/card와 environment profile을 composition; +- startup readiness exposure; +- business use case를 구현하지 않는다. + +## 9. Application 계약 + +### 9.1 Port 분리 + +초기 target interface family는 다음 의미를 가진다. 이름은 구현 계획에서 Java naming 규칙에 +맞춰 확정하되 의미를 합치지 않는다. + +```java +public interface ManagedObjectPublicationPort { + ObjectPublishReceipt publish( + ObjectPublishRequest request, + ObjectContentProducer producer); +} + +public interface ObjectInspectionPort { + Optional inspect(ObjectReference reference); +} + +public interface ObjectTransferPort { + ObjectReadReceipt transfer( + ObjectReadRequest request, + ObjectContentConsumer consumer); +} + +public interface ObjectRetirementPort { + ObjectMutationReceipt retire(ObjectRetireRequest request); +} + +public interface ObjectPurgeMaintenancePort { + ObjectMutationReceipt purge(ObjectPurgeRequest request); +} + +public interface ObjectOperationResolutionPort { + ObjectOperationResolution resolve(ObjectOperationKey operationKey); +} + +public interface ObjectPublicationHandoffPort { + ObjectHandoffReceipt claimForPublication(ObjectHandoffClaimRequest request); + ObjectHandoffReceipt renewClaim(ObjectHandoffRenewRequest request); + ObjectMutationReceipt releaseClaim(ObjectHandoffReleaseRequest request); + ObjectMutationReceipt authorizeAbort(ObjectAbortAuthorization request); +} + +public interface DirectObjectUploadPort { + DirectUploadGrant createUploadGrant(DirectUploadGrantRequest request); + DirectUploadCompletionReceipt completeUpload(DirectUploadCompletionRequest request); +} + +public interface DirectObjectDownloadGrantPort { + DirectDownloadGrant createDownloadGrant(DirectDownloadGrantRequest request); +} + +public interface DirectMultipartUploadPort { + MultipartSession startMultipart(MultipartStartRequest request); + PartUploadGrant createPartGrant(PartUploadGrantRequest request); + PartReceiptToken acknowledgePart(MultipartPartAcknowledgement request); + MultipartReceipt completeMultipart(MultipartCompleteRequest request); + ObjectMutationReceipt abortMultipart(MultipartAbortRequest request); +} +``` + +Quarantine가 필요한 workflow에는 single-call `publish`만으로 부족하다. 다음 staged family를 +분리한다. + +```java +public interface StagedObjectPublicationPort { + ObjectStageReceipt stage(ObjectStageRequest request, ObjectContentProducer producer); + ObjectVerificationReceipt verifyIntegrity(ObjectVerifyRequest request); + ObjectPublishReceipt finalizePublication(ObjectFinalizeRequest request); + ObjectMutationReceipt abort(ObjectAbortRequest request); +} + +public interface ObjectScanMaintenancePort { + ObjectReadReceipt transferForScan( + ObjectScanReadRequest request, + ObjectContentConsumer consumer); + ObjectMutationReceipt recordScanVerdict(ObjectScanVerdictRequest request); +} +``` + +`ManagedObjectPublicationPort.publish`는 compiled destination이 scan-free publication을 +명시한 경우에만 사용한다. Scan-gated destination을 이 편의 port로 호출하면 +`UNSUPPORTED_CAPABILITY`가 아니라 configuration/programming error로 fail closed하며 반드시 +staged family를 사용한다. + +모든 mutation request와 resolution은 공통 `ObjectOperationKey`를 포함한다. Operation record의 +physical route를 current provider default로 추론하지 않는다. §10.2의 destination route token, +epoch registry, retained binding revision을 통해 exact control namespace를 찾는다. + +Direct operation의 의미: + +- `completeUpload`: client completion claim을 신뢰하지 않고 exact HEAD/checksum/size/encryption을 + 검증한 뒤 staged receipt를 만든다; +- `createDownloadGrant`: application authorization 뒤 published exact version에 대한 bounded GET + grant를 만든다; +- `acknowledgePart`: client가 반환한 bounded provider completion claim을 검증하고 server-side + opaque part token/ledger로 바꾼다; +- `completeMultipart`: server ledger의 part token만 받아 provider complete와 reconciliation을 + 수행한다. + +Scan operation은 normal staged publication과 다른 maintenance 권한 port를 사용한다. +`ObjectScanMaintenancePort`의 의미: + +- `transferForScan`은 unpublished exact version만 scanner workflow에 bounded read한다; +- `recordScanVerdict`는 object version, scan operation, scanner policy/version precondition이 + 일치할 때만 `CLEAN`, `MALICIOUS`, `INDETERMINATE` verdict를 기록한다; +- stale/duplicate verdict는 current object에 적용하지 않는다. + +`ObjectScanMaintenancePort`와 `ObjectPurgeMaintenancePort`는 일반 business use case에 주입하지 +않는다. Scanner workflow용 maintenance composition과 physical purge용 privileged composition도 +서로 다른 concrete router/client 권한으로 유지한다. Maintenance +composition 또는 명시적으로 승인된 privileged administration workflow만 사용한다. +`ObjectOperationResolutionPort`는 read-only recovery service가 공유한다. +`ObjectPublicationHandoffPort`는 application DB/outbox와 adapter reaper 사이의 destructive +cleanup fence다. Objectstorage adapter가 persistence repository를 직접 조회하지 않는다. + +구현 시 interface를 과도하게 세분화하지 않되 다음 권한 경계는 유지한다. + +- inspect/read; +- managed write/stage/finalize; +- scanner unpublished-read/verdict maintenance; +- direct upload, direct download grant, multipart; +- business retirement; +- privileged physical purge; +- maintenance/reconciliation. + +Maintenance port는 application business service에 주입하지 않고 운영 job/composition에만 +노출한다. + +### 9.2 Identity type + +필수 identity: + +| Type | 의미 | 생성 주체 | +| --- | --- | --- | +| `ObjectDestinationId` | logical storage/security/retention 목적지 | application/config | +| `ObjectOperationEpoch` | operation namespace rotation/rejection epoch | composition/application ID factory | +| `ObjectOperationId` | mutation 한 건의 stable idempotency identity | application | +| `ObjectOperationKey` | destination, epoch, operation을 묶은 모든 mutation의 exact key | application | +| `ObjectId` | immutable data object identity | adapter | +| `ObjectStageHandle` | unpublished exact object를 stage/scan/finalize에만 쓰는 opaque handle | adapter | +| `ObjectReference` | application이 저장하는 opaque published reference | adapter | +| `ObjectVersionToken` | exact immutable generation/version precondition | adapter | +| `DirectTransferSessionId` | direct upload workflow identity | adapter | +| `MultipartPartNumber` | bounded 1-based logical part number | application/adapter validation | +| `PartReceiptToken` | provider part result를 숨긴 opaque token | adapter | + +모든 ID는: + +- null/blank를 허용하지 않는다; +- canonical text form을 가진다; +- log용 hash/token을 별도로 제공한다; +- provider locator를 encode하지 않는다; +- parsing이 bounded이고 exception message에 secret/raw content를 넣지 않는다. + +`ObjectOperationId`는 HTTP request ID와 다르다. 동일 business mutation retry에서 유지되고, 새 +사용자 의도에는 새 ID를 사용한다. + +`ObjectStageHandle`은 published reference가 아니며 public DTO/download port에 사용할 수 없다. +`ObjectStageReceipt`가 이를 반환하고 finalize success가 처음으로 `ObjectReference`를 만든다. + +Destination ID의 provider/namespace binding은 한 번 published operation이 생기면 in-place로 +재지정하지 않는다. Provider migration은 새 destination route token/binding revision을 만들고 +기존 binding을 read/reconcile/drain 상태로 보존한다. 그렇지 않으면 +`destinationId + operationId` retry가 과거 control record를 찾지 못한다. + +### 9.3 Content callback + +Java standard `InputStream`을 port에 그대로 넘기지 않는다. Baseline은 synchronous callback이다. + +```java +@FunctionalInterface +public interface ObjectContentProducer { + void produce( + ObjectContentProductionContext context, + ObjectChunkSink sink) + throws ObjectContentProductionException; +} + +public interface ObjectChunkSink { + void write(byte[] bytes, int offset, int length) + throws ObjectChunkWriteException; +} + +@FunctionalInterface +public interface ObjectContentConsumer { + void consume( + ObjectContentReadContext context, + ObjectChunkSource source) + throws ObjectContentConsumptionException; +} + +public interface ObjectChunkSource { + int read(byte[] destination, int offset, int length) + throws ObjectChunkReadException; +} +``` + +계약: + +- context는 read-only `CancellationView`, remaining deadline/budget checkpoint를 제공한다; +- read context는 body 전에 validated descriptor, exact version, delivered range/length를 제공한다; +- callback이 반환되면 producer/consumer가 provider resource를 보관할 수 없다; +- adapter가 sink/source를 닫고 invalidation한다; +- source는 EOF에 `-1`, positive request에서 progress가 없으면 bounded zero-read 후 protocol + failure를 반환한다; +- offset/length/array bounds를 호출 전에 검증한다; +- producer의 input array는 `write` 반환까지만 유효하며 adapter가 반환 뒤 reference를 보관하지 + 않는다; +- consumer destination array는 `read`를 호출한 consumer가 소유하며 adapter가 보관하지 않는다; +- configured max chunk보다 큰 write를 쪼개거나 거부한다; +- producer가 던진 application 오류와 storage 오류를 분리한다; +- producer는 한 operation attempt에서 기본적으로 한 번만 호출한다; +- 재호출이 필요하면 request가 repeatable임을 명시하고 별도 factory를 사용한다; +- consumer failure, cancellation, disconnect는 read success가 아니다; +- adapter는 application callback을 SDK event-loop thread에서 실행하지 않는다; +- callback이 blocking임을 contract에 명시한다. + +향후 reactive port가 필요하면 별도 capability로 설계한다. `Flow.Publisher`, Reactor type 또는 +AWS `AsyncRequestBody`를 이 baseline port에 노출하지 않는다. + +### 9.4 Publish request + +`ObjectPublishRequest`의 최소 필드: + +- `ObjectOperationKey operationKey`; +- `ObjectMediaType declaredMediaType`; +- `ObjectContentIdentity contentIdentity`; +- `ObjectPublicationRequirement publicationRequirement`; +- `ObjectRetentionRequirement retentionRequirement`; +- `ObjectEncryptionRequirement encryptionRequirement`; +- `OperationDeadline deadline`; +- bounded correlation/audit context. + +넣지 않는 필드: + +- bucket; +- raw key/path; +- region/endpoint; +- AWS storage class enum; +- KMS raw key ARN; +- public ACL; +- arbitrary metadata map; +- original filename; +- inbound DTO. + +Destination binding이 provider와 namespace, maximum size, checksum, encryption, retention, +direct-transfer 허용 여부를 결정한다. + +R2 `ObjectContentIdentity`는 다음 중 하나다. + +- exact byte length + expected full SHA-256; +- adapter가 검증 가능한 immutable source reference/revision + expected length/digest. + +Maximum size만 있고 payload digest가 없는 one-shot upload는 다른 payload로 operation ID를 +오용했을 때 conflict를 검출할 수 없다. 이 경로는 R0/R1 compatibility로만 허용하고 R2 +publication card에 포함하지 않는다. + +### 9.5 Read request + +`ObjectReadRequest`: + +- exact `ObjectReference`; +- optional expected `ObjectVersionToken`; +- `ObjectReadRange`; +- digest verification mode; +- maximum delivered bytes; +- deadline/cancellation. + +Public `ObjectTransferPort`는 caller가 publication/scan requirement를 낮추는 field를 받지 않는다. +항상 compiled destination minimum인 `PUBLISHED`와 required `SCAN_CLEAN`을 강제한다. Unpublished +exact-version read는 별도 narrow scan/maintenance port와 bean만 제공한다. + +Baseline range는 하나의 contiguous `(offset, length)`다. + +- offset은 0 이상; +- length는 1 이상이며 destination maximum 이하; +- object end를 넘는 range의 exact 결과를 정의한다; +- suffix/multi-range HTTP 문법은 inbound에서 canonical form으로 변환한다; +- provider range response의 실제 offset/length/content-range를 검증한다. + +### 9.6 Descriptor와 receipt + +`ObjectDescriptor`: + +- opaque reference; +- exact version token; +- logical size; +- declared/detected media type; +- content digest algorithm/value; +- publication state; +- scan state; +- encryption profile ID; +- retention state; +- created/published timestamp; +- schema version. + +`ObjectPublishReceipt`: + +- operation ID; +- request fingerprint; +- opaque reference; +- exact version; +- size; +- digest; +- media type; +- terminal outcome; +- applied timestamp; +- effective capability descriptor revision. + +Receipt에는 bucket, provider key, filesystem path, endpoint, raw ETag, upload ID를 넣지 않는다. +Provider-specific evidence는 private operation record에만 저장한다. + +### 9.7 Capability requirement + +Request는 필요한 보장을 typed enum/set으로 표현한다. + +- immutable create; +- exact version read; +- conditional retirement; +- SHA-256 verification; +- scan-gated publication; +- direct upload; +- direct multipart; +- retention hold; +- server-side encryption profile; +- response-loss reconciliation. + +Binding compiler는 destination의 required capability와 provider의 effective capability를 +startup에서 대조한다. Runtime request가 compiled binding보다 강한 보장을 요구하면 호출 전에 +`UNSUPPORTED_CAPABILITY`로 거부한다. + +Runtime request의 scan, encryption, retention, checksum, publication requirement는 destination +minimum을 강화할 수만 있고 낮출 수 없다. Weaker request는 stronger destination policy로 +승격하거나 ambiguous하면 fail closed한다. + +## 10. Logical key, object reference, namespace + +### 10.1 Internal data key + +Provider key는 adapter 내부에서 생성한다. 예시 grammar: + +```text +data/v1//// +``` + +요구사항: + +- ASCII lower-case의 제한된 alphabet; +- segment 길이와 전체 길이 제한; +- `.`/`..`, empty segment, slash alias 금지; +- percent/Unicode normalization ambiguity 금지; +- tenant/user/original filename/email 같은 PII 금지; +- object ID에서 deterministic shard 계산; +- destination별 private prefix 고정; +- generation은 immutable create마다 새 값; +- canonical encoder와 parser에 property test; +- provider별 key normalization 차이를 adapter kernel에서 제거. + +### 10.2 Control namespace + +```text +control/v1/operations//// +control/v1/references/// +control/v1/manifests//// +control/v1/multipart/// +control/v1/reaper-cursors// +control/v1/operation-epochs// +``` + +Data와 control prefix는 IAM/policy와 lifecycle에서 분리한다. Runtime identity가 broad bucket +list/delete 권한을 갖지 않도록 operation별 최소 prefix 권한을 설계한다. + +`destination-token`은 stable, non-secret route identity다. 동일 token의 provider/namespace를 +in-place로 바꾸지 않는다. Migration은 새 token을 만들며 old token의 binding revision을 +read/reconcile/retire 기간 동안 보존한다. + +### 10.3 Public opaque reference + +예시 외형: + +```text +osr1... +``` + +Reference는: + +- provider/bucket/key를 복호화할 수 없는 opaque value; +- 오타 탐지용 check digits; +- schema/version prefix; +- destination route token; +- 충분한 entropy; +- application DB에 저장 가능한 bounded string; +- secret이 아니지만 log에서는 hash/token 처리; +- authorization을 대체하지 않음; +- public download URL이 아님. + +Reference를 받은 사용자는 application authorization을 통과해야만 stream 또는 presigned grant를 +받는다. + +Route registry는: + +- token -> destination/binding revision history를 durable하게 보존; +- destination display-name rename과 route identity를 분리; +- live reference/operation/tombstone가 남아 있으면 token 삭제·재사용 금지; +- provider migration 시 old route를 read/reconcile/retire 상태로 유지; +- unknown/removed route를 current default provider로 보내지 않음; +- route alias/tombstone retention을 reference maximum lifetime보다 길게 유지 + +한다. Startup validation은 token collision/reuse와 required old binding 부재를 hard fail한다. + +### 10.4 Original filename와 user metadata + +Original filename은 object key로 사용하지 않는다. Business상 필요하면: + +- inbound에서 control character/path separator를 제거한다; +- length를 제한한다; +- public response용 display metadata로 application DB에 저장한다; +- storage adapter control record에는 allowlisted, encoded metadata만 둔다; +- log/metric tag에 넣지 않는다. + +Arbitrary `Map` metadata는 baseline port에 없다. 필요한 metadata는 typed, +versioned field로 추가한다. + +## 11. Operation identity와 request fingerprint + +### 11.1 Canonical fingerprint + +Fingerprint는 content bytes 자체가 아니라 immutable request intent를 canonical encode한 뒤 +SHA-256으로 계산한다. + +포함: + +- schema version; +- destination ID; +- operation kind; +- declared media type canonical form; +- exact/maximum size expectation; +- exact R2 content identity 또는 explicit R1 compatibility marker; +- publication/scan requirement; +- encryption/retention profile; +- direct/multipart parameter; +- prior reference/version precondition. + +제외: + +- request ID, trace ID; +- current time; +- provider-generated key/upload ID; +- presigned URL; +- credential; +- transient retry count. + +Canonical encoding은: + +- field 순서 고정; +- enum canonical name 고정; +- number decimal encoding 고정; +- absence와 empty를 구분; +- Unicode normalization 정책 고정; +- schema version 포함; +- golden vector test 보유. + +### 11.2 Same-operation decision + +| 기존 operation | 새 요청 | 결과 | +| --- | --- | --- | +| 없음 | valid fingerprint | reserve 후 실행 | +| non-terminal, same fingerprint | retry | current state/continuation 반환 | +| terminal success, same fingerprint | retry | 저장된 receipt 반환, producer 재호출 금지 | +| terminal failure, same fingerprint | retry | 정책에 따라 same failure 또는 explicit new operation 요구 | +| 어떤 상태든 different fingerprint | retry | `OPERATION_CONFLICT` | +| unknown/newer schema | retry | fail closed, manual/upgrade reconciliation | + +Operation ID uniqueness만 보고 dedupe하지 않는다. Fingerprint 비교가 필수다. + +### 11.3 Content digest와 operation fingerprint 분리 + +두 digest를 혼동하지 않는다. + +- request fingerprint: 같은 사용자 의도인지 판정; +- content digest: 업로드된 byte가 기대한 content인지 판정. + +R2 expected content digest는 fingerprint에 포함하고 adapter가 streaming 중 계산한 값과 비교한다. +R1 compatibility 경로는 adapter가 discovered digest를 기록하지만, terminal retry에서 다른 +payload 오용을 검출하지 못한다는 보장 한계를 descriptor/receipt에 표시한다. Direct upload는 +server가 bytes를 직접 보지 않을 수 있으므로 provider checksum/head 또는 별도 verification +read가 필요하다. + +### 11.4 Replay horizon과 tombstone + +Destination은 다음보다 긴 `minimumOperationReplayHorizon`을 가진다. + +- public API idempotency retry horizon; +- outbox/redelivery/dead-letter recovery horizon; +- maximum worker outage; +- provider indeterminate reconciliation horizon. + +Operation identity는 `destination route + operation epoch + operation ID`다. + +- active epoch에서는 새 operation을 받을 수 있다; +- active epoch의 per-operation tombstone은 개별 horizon 경과만으로 삭제하지 않는다; +- compaction하려면 epoch를 먼저 `SEALED`로 바꿔 신규 operation을 거부한다; +- seal 뒤 maximum replay horizon과 indeterminate/retention recovery가 모두 끝날 때까지 individual + receipt/tombstone을 유지한다; +- 그 뒤 immutable epoch rejection record를 남기고 per-operation tombstone을 compact할 수 있다; +- sealed/compacted epoch로 온 어떤 operation ID도 exact lookup 결과와 무관하게 + `OPERATION_EXPIRED`를 반환한다; +- epoch token은 destination lifetime 동안 재사용하지 않는다. + +따라서 tombstone 삭제 뒤 “record 없음”을 새 operation으로 오인하지 않는다. Current active +epoch의 tombstone을 지우고 같은 epoch를 계속 쓰는 구현은 금지한다. Application operation-ID +factory와 outbox는 epoch를 operation과 함께 durable하게 보존한다. + +### 11.5 Bounded epoch rotation + +```text +WARM -> ACTIVE -> DRAINING -> SEALED -> COMPACTED +``` + +Finite destination settings: + +- `epochMaxAge`; +- `epochMaxOperations`; +- `epochMaxControlBytes`; +- maximum concurrently readable old epochs; +- drain/replay/compaction deadline. + +Rotation: + +1. 새 epoch를 `WARM`으로 만들고 codec/policy/binding 및 모든 reader가 old/new lookup을 지원하는지 + qualification한다. +2. Durable current-write-epoch pointer를 CAS로 새 epoch에 전환한다. +3. Old epoch를 `DRAINING`으로 바꾸고 stale writer admission을 거부한다. +4. maximum in-flight request/outbox dispatch horizon 동안 old operation continuation/replay만 + 허용한다. +5. unresolved indeterminate operation이 없고 drain fence가 확인되면 `SEALED`한다. +6. replay/retention horizon 뒤 immutable rejection record를 남기고 `COMPACTED`한다. + +Pointer unavailable, stale writer, seal race는 fail closed한다. Rollback은 새 epoch에 operation이 +없을 때만 pointer를 되돌리거나 또 다른 epoch를 사용한다. Multi-pod cutover, delayed outbox, +partitioned writer, restore를 rolling/fault test한다. + +## 12. 분리된 상태 머신 + +Publication operation, published reference lifecycle, retirement/purge mutation, direct grant +session, multipart session을 한 enum에 합치지 않는다. 각각 별도 record와 stable operation +identity를 가진다. + +### 12.1 Publication operation + +```text +RESERVED + -> DATA_UPLOAD_IN_PROGRESS + -> DATA_UPLOADED + -> INTEGRITY_VERIFIED + -> SCAN_PENDING + -> SCAN_CLEAN + -> REFERENCE_PUBLISH_IN_PROGRESS + -> PUBLISHED +``` + +Terminal: + +```text +PUBLISHED | ABORTED | QUARANTINED | EXPIRED | FAILED | CORRUPT +``` + +Scan이 필요 없는 destination은 `INTEGRITY_VERIFIED -> REFERENCE_PUBLISH_IN_PROGRESS`로 +전이한다. `PUBLISHED`는 이 publication operation의 immutable terminal이다. 이후 retirement가 +같은 record를 변경하지 않는다. + +### 12.2 Scan sub-state + +```text +NOT_REQUIRED +PENDING +CLEAN +MALICIOUS +INDETERMINATE +``` + +- destination minimum이 scan을 요구하면 caller가 `NOT_REQUIRED`로 낮출 수 없다; +- verdict는 exact object version, scan operation, scanner policy revision, fence를 묶는다; +- `MALICIOUS`는 publication operation을 `QUARANTINED` terminal로 보낸다; +- `INDETERMINATE`는 clean이 아니며 scan retry/운영 판정 전 publish를 막는다. + +### 12.3 Published reference lifecycle + +```text +PUBLISHED + -> RETIREMENT_PENDING + -> RETIRED + -> PURGE_ELIGIBLE + -> PURGED +``` + +Reference lifecycle record는 publication receipt와 별도다. + +- retirement mutation마다 새 `ObjectOperationId`와 fingerprint를 사용한다; +- physical purge도 retirement와 다른 privileged operation ID를 사용한다; +- `RETIRED`는 business visibility 제거이며 physical purge와 다르다; +- retention/legal hold는 purge operation outcome을 `HELD`로 만들며 reference record를 임의로 + `PURGED`로 승격하지 않는다; +- reference가 가리키는 immutable generation은 lifecycle 전체에서 덮어쓰지 않는다. + +### 12.4 Direct grant session + +```text +SESSION_RESERVED + -> GRANT_PREPARED + -> GRANT_ISSUED + -> UPLOAD_VERIFICATION_IN_PROGRESS + -> DATA_UPLOADED +``` + +Terminal/branch: + +```text +EXPIRED | ABORTED | FAILED +``` + +Grant generation, constraints digest, signing revision, expiry, outstanding-grant exposure는 session +record가 소유한다. Download grant도 별도 grant-operation record와 published reference revision +precondition을 가진다. Grant response uncertainty는 stable grant state와 pending-effect certainty로 +표현한다. 상세 발급/reissue linearization은 §18.6을 따른다. + +### 12.5 Multipart session + +```text +SESSION_RESERVED + -> INITIATE_IN_PROGRESS + -> ACCEPTING_PARTS + -> COMPLETE_IN_PROGRESS + -> COMPLETED +``` + +Terminal/branch: + +```text +ABORTED | EXPIRED | FAILED | CORRUPT +``` + +Session record는 provider upload ID, part ledger, grant fence를 private하게 보존한다. `COMPLETED`가 +되면 publication operation의 `DATA_UPLOADED` evidence로 연결되지만 두 record를 같은 state로 +간주하지 않는다. + +### 12.6 Pending effect와 certainty + +단일 `INDETERMINATE` state로 phase를 지우지 않는다. 모든 external mutation 직전에 control +record를 CAS하여 다음을 기록한다. + +```text +stablePhase +pendingEffect { + kind + attemptId + exactTargetAndVersion + desiredRevisionOrState + precondition + requestEvidenceDigest +} +certainty = NOT_SENT | SENT | CONFIRMED | INDETERMINATE +``` + +`kind` 예: + +- `DATA_PUT`; +- `DIRECT_GRANT_ISSUE`; +- `DIRECT_UPLOAD_VERIFY`; +- `DIRECT_DOWNLOAD_GRANT_ISSUE`; +- `MULTIPART_INITIATE`; +- `MULTIPART_PART`; +- `MULTIPART_COMPLETE`; +- `MULTIPART_ABORT`; +- `REFERENCE_CAS`; +- `REFERENCE_RETIRE`; +- `OBJECT_PURGE`; +- `CONTROL_RECORD_CAS`. + +따라서 `DATA_PUT_INDETERMINATE`, `REFERENCE_CAS_INDETERMINATE`, +`MULTIPART_COMPLETE_INDETERMINATE`, `OBJECT_PURGE_INDETERMINATE`를 서로 다른 reconciler +branch로 보낸다. + +### 12.7 전이 규칙 + +- terminal operation record는 immutable하게 보존한다; +- state version/CAS와 pending-effect precondition이 일치할 때만 전이한다; +- state마다 allowed predecessor를 고정한다; +- 같은 terminal transition retry는 stored receipt를 반환한다; +- out-of-order callback은 무시하지 말고 conflict/audit한다; +- object data 존재만으로 publication state를 승격하지 않는다; +- state와 provider evidence가 충돌하면 `CORRUPT` 또는 phase-specific indeterminate로 격리한다; +- quarantined/unpublished object는 public transfer/grant 대상이 아니다. + +### 12.8 Mutation response loss + +Response loss가 발생하면: + +1. pending effect와 certainty를 `INDETERMINATE`로 유지한다; +2. 같은 mutation을 blind retry하지 않는다; +3. effect kind에 맞는 exact key/version/upload session/control revision을 조회한다; +4. size, digest, metadata, version, state evidence를 비교한다; +5. `APPLIED`, `NOT_APPLIED`, `PRECONDITION_FAILED`, 계속 `INDETERMINATE` 중 하나로 resolve한다; +6. resolve 결과에 따라 continuation 또는 compensation을 수행한다. + +## 13. Durable control plane + +### 13.1 필요성 + +Provider object data만으로 다음을 복구할 수 없다. + +- operation ID와 request fingerprint; +- scan/publication 상태; +- provider response loss 전후 의도; +- old/new attachment reference; +- cleanup ownership/age; +- multipart part ledger; +- schema/policy/encryption revision; +- terminal receipt. + +따라서 R2에는 durable control plane이 필요하다. + +### 13.2 Source of truth + +초기 선택은 같은 object storage의 private control namespace에 versioned canonical record를 두는 +것이다. + +- data plane과 failure domain이 같아 disaster recovery가 단순하다; +- conditional create/CAS를 provider capability로 검증할 수 있다; +- application DB schema와 capability internals를 분리한다. + +단, control record와 data object는 여전히 단일 atomic transaction이 아니다. protocol과 +reconciliation이 crash gap을 닫는다. + +Control plane을 별도 database에 두는 선택도 가능하지만 이 문서의 baseline이 아니다. 바꾸려면: + +- ownership과 dependency edge; +- transaction/capacity/failover; +- dual-store recovery; +- backup/restore ordering + +을 별도 설계로 승인한다. + +### 13.3 Record schema + +Operation record v1 최소 필드: + +- schema version; +- operation ID; +- canonical request fingerprint; +- operation kind; +- destination route token; +- provider/binding/policy revision; +- canonical non-secret effective policy snapshot와 digest; +- key grammar/codec/checksum/encryption/retention plan revision; +- logical credential/key reference revision; +- internal object ID/key hash; +- opaque reference; +- operation family, stable phase, family-specific state/state version; +- pending effect, attempt ID, precondition, request evidence digest, certainty; +- expected/observed size; +- expected/observed content digest; +- provider checksum algorithm/value; +- provider version/ETag private evidence; +- multipart opaque session and completed-part ledger; +- encryption/retention/scan profile revision; +- timestamps and bounded lease/fence; +- last normalized outcome/error; +- terminal public receipt; +- minimum replay-until/terminal tombstone epoch; +- cleanup ownership/eligibility; +- application handoff/cleanup authorization fence; +- audit correlation token. + +Record는 canonical format으로 encode하고: + +- checksum/MAC 또는 authenticated encryption 정책; +- maximum record size; +- unknown field policy; +- schema upgrade/downgrade behavior; +- corruption handling; +- golden fixtures + +를 테스트한다. Secret, presigned URL, raw credential은 기록하지 않는다. + +Reserve 시 effective execution policy를 freeze한다. Same operation retry/reconcile은: + +- 저장된 policy snapshot과 digest; +- 저장된 provider/binding revision; +- 저장된 key/codec/checksum/encryption/retention plan; +- 당시의 logical credential/key revision + +만 사용한다. Current configuration으로 다시 resolve하거나 silent fallback하지 않는다. 필요한 old +revision을 복원할 수 없으면 `POLICY_REVISION_UNAVAILABLE`로 fail closed하고 manual +reconciliation 대상으로 보낸다. + +Old revision은 maximum operation/replay/retention horizon보다 길게 보존한다. 새 policy는 새 +operation에만 적용한다. Rolling deployment test는 old revision read/continue와 new revision +write를 함께 검증한다. + +### 13.4 Conditional update + +R2 provider는 control record에 다음 중 하나를 증명해야 한다. + +- create-if-absent와 exact version compare-and-swap; +- immutable revision append + conditional current pointer; +- 동등한 linearizable primitive. + +지원하지 않는 provider는 multi-node deterministic recovery를 claim하지 않는다. + +LIST는 reaper candidate discovery에만 사용한다. Operation/reference direct lookup의 source of +truth는 deterministic exact key GET/HEAD다. + +### 13.5 Schema evolution + +- reader는 자신보다 낮은 supported version을 migrate in memory할 수 있다; +- writer는 deployment의 selected write version만 쓴다; +- newer version은 삭제/overwrite하지 않고 `UNSUPPORTED_CONTROL_SCHEMA`로 격리한다; +- rolling deployment 동안 old/new reader compatibility matrix를 CI에서 검증한다; +- downgrade 전에 write-version gate를 낮추는 별도 단계가 필요하다; +- cleanup job은 unknown schema를 보고만 하고 삭제하지 않는다. + +## 14. Managed upload protocol + +### 14.1 Plan + +Adapter는 content producer를 호출하기 전에: + +1. operation key의 destination route token, epoch, operation ID로 existing record 또는 sealed + epoch rejection record를 exact lookup한다; +2. existing record가 있으면 frozen binding/policy revision을 복원하고 fingerprint를 비교한다; +3. record가 없을 때만 current destination binding과 capability requirement를 resolve한다; +4. operation ID/fingerprint와 R2 content identity를 검증한다; +5. immutable object ID/key와 opaque reference를 생성한다; +6. size/digest/encryption/retention limit와 effective policy snapshot/digest를 freeze한다; +7. `RESERVED` control record를 create-if-absent한다; +8. winning record를 다시 읽어 same-operation decision을 수행한다; +9. operation-scoped resource budget을 예약한다. + +이 단계에는 data write가 없다. + +### 14.2 Upload + +1. control state를 CAS로 `DATA_UPLOAD_IN_PROGRESS`로 전이하고 `DATA_PUT` pending effect를 + `NOT_SENT`로 기록한다. +2. provider immutable-create request를 열기 직전에 pending effect certainty를 `SENT`로 + 전이한다. +3. producer callback의 chunk를 bounded buffer로 전달한다. +4. 동시에 logical byte count와 SHA-256을 계산한다. +5. provider transport checksum을 지원하면 별도로 계산/전송한다. +6. maximum bytes를 넘기기 전에 sink를 중단한다. +7. producer, adapter, SDK 오류를 서로 다른 normalized cause로 기록한다. +8. successful provider response에서 exact version/checksum/encryption evidence를 보존한다. +9. response가 확정되면 pending effect를 confirm/clear하고 `DATA_UPLOADED`로 전이한다. + 불확정이면 stable phase는 `DATA_UPLOAD_IN_PROGRESS`, pending effect kind는 `DATA_PUT`, + certainty는 `INDETERMINATE`로 유지한다. + +Producer exception 뒤에는 partial provider upload를 abort/cleanup한다. Cleanup 실패가 원래 +producer 오류를 덮지 않으며 operation record에 별도 evidence로 남는다. + +### 14.3 Integrity verification + +검증 우선순위: + +1. caller expected SHA-256과 adapter-calculated SHA-256 비교; +2. sent provider checksum과 provider response/head checksum 비교; +3. exact object size 비교; +4. exact immutable version 확인; +5. encryption/retention response attestation 확인; +6. provider 특성상 response evidence가 불충분하면 bounded verification read. + +불일치 시 object를 publish하지 않고 `QUARANTINED` 또는 `CORRUPT`로 보낸다. 단순 retry로 +정상화하지 않는다. + +### 14.4 Scan + +Scan이 필요한 destination: + +```text +DATA_UPLOADED + -> INTEGRITY_VERIFIED + -> scanState=PENDING + -> scanner application workflow + -> scanState=CLEAN | MALICIOUS | INDETERMINATE +``` + +`scanState=CLEAN`만 publication state를 `SCAN_CLEAN`으로 진행시킨다. `MALICIOUS`는 +`QUARANTINED` terminal, `INDETERMINATE`는 `SCAN_PENDING` publication state에 머문다. + +Object Storage adapter는 scanner SDK나 business verdict를 소유하지 않는다. 다음 seam만 제공한다. + +- unpublished exact version에 대한 authorized bounded read; +- scan operation correlation; +- clean/malicious/indeterminate verdict를 conditional state transition으로 기록; +- stale verdict가 새 version에 적용되지 않도록 version precondition; +- scanner unavailable 시 fail-closed publication. + +Scanner capability는 별도 outbound adapter가 application port 뒤에서 구현한다. Objectstorage +leaf가 sibling adapter를 직접 의존하지 않는다. + +### 14.5 Publish + +기본 publication: + +1. required integrity/scan state를 재검증한다; +2. immutable manifest revision을 기록한다; +3. reference current pointer를 conditional create/CAS한다; +4. `PUBLISHED` terminal receipt를 operation record에 보존한다; +5. caller에게 opaque receipt를 반환한다. + +Data object key는 이동/복사/ACL 변경하지 않는다. Reference lookup이 private immutable object의 +exact version을 가리킨다. + +Manifest/pointer write response가 유실되면 reference exact GET과 revision/digest 비교로 +reconcile한다. + +### 14.6 Abort + +Abort는: + +- terminal published object를 지우지 않는다; +- non-terminal object와 multipart session만 대상으로 한다; +- operation ID/fingerprint를 확인한다; +- active publication handoff claim이 있으면 destructive abort를 거부한다; +- business-owned verified stage는 DB upload-intent의 terminal abort fence에 묶인 + `ObjectAbortAuthorization`을 검증한다; +- conditional state transition으로 single owner를 확보한다; +- provider delete/abort 결과를 reconcile한다; +- retention/hold가 있으면 `HELD`를 반환한다; +- cleanup failure를 숨기지 않는다. + +### 14.7 Empty object + +Empty object 지원 여부를 destination별로 명시한다. + +- generic binary destination은 size 0을 허용할 수 있다; +- poster image 같은 business destination은 application policy로 거부한다; +- checksum은 empty SHA-256의 정상 값으로 계산한다; +- multipart는 empty object에 사용하지 않는다. + +## 15. Database attachment workflow + +### 15.1 금지 shape + +```text +DB transaction { + remote upload + aggregate save +} +``` + +이 shape는 사용하지 않는다. + +### 15.2 권장 workflow + +Poster image 교체 예시: + +```text +1. authorize + validate request +2. stable attachment operation 생성 +3. short DB transaction: + Poster exists/version 확인 + versioned HMAC key epoch을 lock하고 raw tenant/principal/Idempotency-Key를 + domain-separated alias/digest로 변환 + tx.inWrite(() -> IdempotencyExecutor.execute( + sanitizedContext, + create-or-read UploadIntent(operation, expected poster version, content identity, RESERVED), + bounded reservation codec)) + generic idempotency COMPLETED reservation과 UploadIntent를 함께 commit +4. object stage/upload outside DB transaction +5. integrity verify + required scan +6. object publication handoff claim을 획득 +7. short DB transaction: + UploadIntent가 RESERVED이고 operation/fence가 같은지 CAS + pending attachment(stage handle, operation, expected object version, handoff fence) 저장 + UploadIntent를 PENDING으로 전이 + outbox AttachmentPrepared(handoff fence) 기록 +8. committed UploadIntent worker가 PENDING row를 claim하고 handoff claim을 갱신한 뒤 + object publication finalize +9. short DB transaction: + same pending stage handle인지 CAS 확인 + finalize receipt의 published reference로 교체해 READY 승격 + UploadIntent를 READY로 전이 + old reference를 retirement queue/outbox에 추가 + outbox AttachmentReady 기록 +10. handoff claim release +11. async old-reference retirement +``` + +UploadIntent는 remote transfer 전에 commit되므로 “DB row가 아직 commit될 수 있는 중인데 absence를 +읽고 abort”하는 race를 제거한다. Handoff fence는 secret authorization이 아니라 +operation/version/claim generation에 묶인 opaque value다. DB commit 여부를 objectstorage +adapter가 추측하지 않는다. + +TX1은 generic idempotency claim, UploadIntent create/read, bounded reservation COMPLETE가 같은 +PostgreSQL transaction에 참여한다. Commit 전 crash는 둘 다 남기지 않고, commit 후에는 +COMPLETED generic row와 matching intent가 함께 남는다. Scope claim은 transaction을 +unique-violation으로 poison하지 않는 PostgreSQL `ON CONFLICT` claim primitive를 사용한다. +Versioned key epoch과 retained HMAC key lookup은 rolling rotation 중 old/new digest가 서로 다른 +intent를 만들지 못하게 하며, generic row와 intent에는 raw header/principal/tenant를 저장하지 +않는다. + +Multipart HTTP 요청의 `RequestFingerprint`는 raw body나 multipart boundary의 hash가 아니다. +`poster-image-publication-fingerprint-v1` canonical codec이 schema/domain separator, Poster ID, expected +aggregate version, destination/profile, normalized media type, declared content length, 그리고 +요청 전에 제출된 full-file SHA-256을 length-prefixed bytes로 직렬화한 semantic digest다. +Filename, multipart boundary, part/header 순서와 transport-only header는 제외한다. 같은 의미의 +재시도는 같은 fingerprint가 되고, content identity나 business precondition이 바뀌면 반드시 +달라지는 golden/property test를 둔다. + +HTTP multipart producer는 request lifetime에 묶이므로 4–7은 successful `202` 반환 전에 같은 +request invocation에서 끝난다. Worker는 request body가 없는 `RESERVED` intent를 임의로 stage하지 +않는다. TX1 뒤 stage 전 crash는 same-idempotency-key/same-fingerprint retry가 같은 operation에 새 +producer를 공급하고, retry가 없으면 bounded expiry/abort/report 대상으로 남긴다. Worker가 +비동기로 소유하는 구간은 durable `PENDING` 이후 finalize/READY다. + +`AttachmentPrepared`/`AttachmentReady` outbox row는 versioned integration notification/audit다. +Object publication의 canonical work queue와 recovery source of truth는 +`poster_image_upload_intent`이며, outbox/broker delivery만을 유일한 wake-up 또는 object operation +journal로 사용하지 않는다. + +### 15.3 Crash gap + +| Crash 위치 | 남는 상태 | 복구 | +| --- | --- | --- | +| TX1 commit 후 stage 전 | COMPLETED reservation + DB RESERVED intent, no object | same-key/same-fingerprint HTTP retry가 새 request producer로 same operation stage; retry가 없으면 expiry/abort report | +| upload 중 | DB RESERVED intent + pending/partial provider effect | exact provider evidence가 있으면 resolver가 reconcile; bytes 재현이 필요하면 HTTP retry producer만 continuation; worker가 request bytes를 invent/replay하지 않음 | +| verified 후 handoff 전 | DB RESERVED intent + exact verified stage evidence | worker가 exact stage/operation claim; staged evidence가 없으면 worker가 stage하지 않음 | +| handoff 후 pending DB commit 전 | active claim + DB RESERVED intent | worker가 pending CAS; claim expiry 후에만 abort CAS | +| pending DB commit 후 finalize 전 | DB PENDING + active/frozen claim | UploadIntent worker가 same operation finalize/claim renew | +| finalize 후 ready DB commit 전 | published reference + pending DB | worker가 reference inspect 후 ready CAS | +| ready DB 후 old retire 전 | new ready + old published | retirement outbox 재처리 | +| retire response loss | old state indeterminate | exact reference/version reconcile | + +Verified/staged object는 age만으로 reaper가 삭제하지 않는다. Application maintenance가 +operation ID로 business DB/UploadIntent와 관련 outbox evidence를 조회한 뒤: + +- pending/ready면 claim을 획득·갱신하거나 finalize를 재개; +- `UploadIntent=RESERVED`이고 active claim이 없으면 DB CAS로 `ABORT_AUTHORIZED` terminal과 + fence를 먼저 기록한 뒤 exact object/version `ObjectAbortAuthorization`을 발급; +- worker의 late `RESERVED -> PENDING` CAS는 `ABORT_AUTHORIZED` 뒤 실패하며 새 operation으로 + restage/manual recovery; +- object는 있는데 matching UploadIntent가 없으면 auto-delete하지 않고 corruption quarantine; +- DB/outbox 확인이 불가능하면 keep/quarantine하고 alert + +한다. DB absence read만으로 abort authorization을 만들지 않는다. 이 protocol은 orphan leak을 +data loss보다 우선하며 cleanup-vs-late-commit/claim-expiry race를 fault test한다. + +### 15.4 Truth priority + +- business visibility의 source of truth는 DB attachment `READY` 상태다; +- object availability의 source of truth는 published reference manifest다; +- 둘 중 하나만 ready이면 workflow가 non-terminal이다; +- HTTP download는 DB authorization/READY 확인 뒤 object reference를 resolve한다; +- DB row 삭제만으로 object가 물리 삭제되었다고 간주하지 않는다. + +### 15.5 Concurrency + +동일 Poster에 concurrent image upload가 오면: + +- 각 upload는 새 immutable reference를 만든다; +- DB aggregate version/pending attachment CAS로 winner를 결정한다; +- loser object는 publish하지 않거나, 이미 publish되었으면 TX3에서 intent를 + `SUPERSEDED` terminal로 전이하면서 exact losing reference/version retirement row를 같은 + transaction에 기록한다. CAS 예외로 TX 전체를 rollback해 cleanup evidence를 잃지 않는다; +- deterministic final key overwrite를 하지 않는다; +- retry는 same operation ID를 유지한다; +- user가 새 image를 선택한 새 의도에는 새 operation ID를 사용한다. + +### 15.6 Delete + +Poster 삭제: + +1. short DB transaction에서 business 삭제와 reference retirement intent/outbox를 기록한다; +2. object retirement worker가 visibility를 제거한다; +3. retention 정책 뒤 physical purge eligibility를 계산한다; +4. exact version conditional purge를 시도한다; +5. hold/retention은 정상 `HELD` 결과로 보존한다. + +Object delete 실패 때문에 이미 승인된 business delete transaction을 장시간 붙잡지 않는다. + +### 15.7 Legacy object adoption + +기존 raw locator를 opaque reference로 채택하는 기능은 일반 upload/publish port에 섞지 않는다. +Deprecated administrative migration seam과 명시적인 maintenance profile에서만 +`REPORT_ONLY` 또는 `APPLY`로 실행한다. `REPORT_ONLY`가 먼저 exact legacy namespace inventory와 +HEAD/read SHA-256 evidence를 bounded manifest로 만들며 mutation은 하지 않는다. + +`APPLY`는 그 immutable manifest와 별도의 canonical Ed25519 이중 승인 문서가 정확히 일치해야 +한다. 승인 문서는 schema version, adoption operation ID, manifest SHA-256, legacy namespace +digest, target destination/namespace, literal mode `APPLY`, 유효 시간, nonce, 서로 다른 trusted +approver 두 명을 묶는다. Permission-checked configured key file 외의 key는 신뢰하지 않는다. +Verifier는 두 서명과 모든 binding을 확인하고 nonce replay record를 exact operation/digest에 +CAS한 뒤, manifest bytes를 다시 hash한 다음에만 per-row inspect/digest/adoption CAS를 허용한다. +동일 terminal operation replay만 idempotent하며 다른 binding의 nonce 재사용은 거부한다. + +Scheduled maintenance runner는 web `SecurityContext`에 의존하지 않고 격리된 execution +identity와 administrative capability를 명시적으로 전달한다. Normal context에는 migration +port, verifier, replay store, runner bean이 하나도 없다. 적용 중 DB CAS loser나 +missing/corrupt/unknown-version object는 durable evidence와 reconciliation 대상으로 남기며, +자동 overwrite/copy/delete를 하지 않는다. Legacy object retirement는 별도 exact-reference +retirement authorization 뒤에 수행한다. + +## 16. Read, HEAD, range + +### 16.1 Inspect + +`inspect(reference)`는: + +1. reference syntax/check digits를 검증한다; +2. deterministic reference control key를 exact GET한다; +3. supported schema와 `PUBLISHED` 상태를 확인한다; +4. exact immutable data key/version을 얻는다; +5. 필요 시 provider HEAD로 size/checksum/version/encryption을 확인한다; +6. normalized descriptor만 반환한다. + +`Optional.empty`는 reference가 존재하지 않을 때만 사용한다. 다음은 typed error다. + +- malformed reference; +- forbidden destination; +- quarantined/retired; +- provider unavailable; +- corrupt manifest; +- unsupported schema. + +### 16.2 Server-mediated download + +- application authorization이 먼저다; +- adapter가 exact version/range precondition으로 provider read를 연다; +- configured buffer와 delivered-byte limit를 적용한다; +- consumer callback이 반환/실패하면 provider response body를 확실히 close/cancel한다; +- full read digest verification은 전체 object를 모두 읽었을 때만 success다; +- range read는 whole-object digest를 “검증 완료”로 표시하지 않는다; +- optional chunk/range digest가 manifest에 있을 때만 range integrity를 별도 증명한다; +- short read, excess read, wrong content-range는 provider protocol failure다; +- client disconnect는 partial delivery outcome이며 storage success와 구분한다. + +### 16.3 Direct download + +Presigned GET은: + +- DB authorization과 published/scan-clean 확인 후 발급한다; +- exact immutable key/version과 response header profile에 묶는다; +- short TTL과 maximum download size policy를 적용한다; +- URL을 log/audit payload에 저장하지 않는다; +- revocation이 필요한 resource에는 사용하지 않거나 매우 짧은 TTL을 사용한다; +- reference retire 후에도 이미 발급한 URL이 만료 전 유효할 수 있음을 계약에 명시한다; +- AWS는 request 시작 시 expiry를 평가하므로 expiry 직전 시작한 transfer가 이후 계속될 수 있고 + connection이 끊겨 재시도하면 실패할 수 있음을 client contract에 명시한다; +- AWS card는 bucket policy의 bounded `s3:signatureAge` upper bound를 qualification 후보로 둔다. + +One-time URL이라고 부르지 않는다. + +### 16.4 HTTP Range mapping + +Inbound HTTP adapter가 `Range`를 지원할 때: + +- single range만 baseline으로 허용한다; +- unsatisfiable range는 application typed outcome을 HTTP 416으로 매핑한다; +- `If-Range`, ETag 같은 HTTP transport semantics를 application port의 raw header로 넘기지 않는다; +- public ETag을 provider ETag 그대로 쓰지 않고 application version token으로 생성한다; +- exact `Content-Length`와 `Content-Range`를 descriptor/receipt로 검증한다. + +## 17. Checksum, media type, metadata + +### 17.1 Digest model + +```text +ObjectDigest { + algorithm: SHA_256 + encoding: LOWERCASE_HEX + value: exactly 64 lowercase hexadecimal characters + scope: FULL_CONTENT +} +``` + +R2 baseline business digest는 SHA-256이다. Provider transport checksum은 별도 value object다. + +```text +ProviderChecksum { + algorithm: CRC32 | CRC32C | CRC64NVME | SHA1 | SHA256 | qualified extension + checksumType: FULL_OBJECT | COMPOSITE + scope: OBJECT | PART(partNumber) + encoding: BASE64 + value +} +``` + +Algorithm만 같은 composite multipart checksum을 full-content SHA-256과 비교하지 않는다. + +- content digest: application-level immutable content identity/integrity; +- transport checksum: provider request/response corruption detection; +- ETag: provider-specific entity/version evidence; +- operation fingerprint: user intent identity. + +서로 대체하지 않는다. + +### 17.2 ETag 규칙 + +- single-part/plain object에서도 ETag을 portable MD5 계약으로 노출하지 않는다; +- multipart ETag은 whole-object MD5가 아니다; +- encryption/provider 구현에 따라 의미가 달라질 수 있다; +- ETag은 private conditional evidence로 보존할 수 있다; +- public API version은 opaque `ObjectVersionToken`이다. + +### 17.3 Direct upload checksum + +Grant에는: + +- required checksum algorithm; +- checksum header/field; +- declared exact/maximum size; +- content type; +- key/version precondition + +을 묶는다. Upload 뒤에는: + +1. client completion claim을 신뢰하지 않는다; +2. exact HEAD로 size/checksum/version을 확인한다; +3. provider가 full SHA-256을 증명하지 못하면 bounded verification read 또는 scan pipeline을 + 수행한다; +4. 검증 전에는 publish하지 않는다. + +### 17.4 Media type + +- inbound `Content-Type`은 declared value일 뿐이다; +- application이 destination별 allowlist를 적용한다; +- scan/content-sniff 단계에서 detected media type을 별도로 기록할 수 있다; +- declared/detected mismatch 정책은 business/application 소유다; +- browser-executable type은 download response의 disposition/CSP/nosniff 정책과 함께 다룬다; +- metadata value에 CR/LF/control character를 허용하지 않는다. + +### 17.5 Metadata budget + +Typed metadata마다: + +- field count; +- key/value byte length; +- character set; +- canonical encoding; +- redaction; +- persistence location; +- public exposure 여부 + +를 고정한다. Provider user-metadata 최대치에 기대어 application input을 무제한 허용하지 않는다. + +## 18. Direct upload grant + +### 18.1 Threat model + +Presigned request는 URL을 가진 주체가 제한된 provider operation을 실행할 수 있는 bearer +credential이다. + +따라서: + +- authenticated/authorized business intent 뒤에만 생성한다; +- TLS 외 endpoint를 production에서 허용하지 않는다; +- query string을 access log/APM/error message에서 redact한다; +- browser history/referrer 노출을 줄이는 client contract를 제공한다; +- TTL은 destination마다 짧은 upper bound를 둔다; +- underlying temporary credential 만료보다 길게 발급하지 않는다; +- provider/client clock skew budget을 빼고 grant expiry가 session expiry보다 먼저 오도록 compile한다; +- production signer/provider의 NTP/clock health가 허용 skew를 넘으면 신규 grant admission을 + fail closed한다; +- signing identity 권한보다 강한 grant를 만들 수 없음을 qualification한다; +- CORS는 exact origin/method/header allowlist로 pre-provision한다. + +### 18.2 Presigned PUT + +Grant에 고정할 항목: + +- method; +- exact immutable internal key; +- expiry; +- content type; +- checksum header; +- encryption headers; +- expected owner/provider profile; +- atomic create-only precondition; +- exact provider-enforced content length 또는 더 엄격한 hard byte ceiling; +- named profile이 요구하는 Object Lock mode/retain-until/legal-hold header; +- allowed signed headers. + +R2 `direct-put` card에서 atomic create-only는 필수다. 같은 URL이 expiry 전 재사용되거나 concurrent +사용되어도 immutable key를 덮어쓸 수 없어야 한다. AWS profile은 signed +`If-None-Match: *`와 bucket-policy enforcement를 qualification한다. Provider가 이를 지원하지 +않으면 `direct-put=UNSUPPORTED`이며 trusted ingress 또는 server-mediated upload로 보낸다. + +Presigned PUT만으로 exact body size를 모든 client/provider 조합에서 강제했다고 주장하지 않는다. +R2 direct admission은 다음 중 하나가 실제 fault/security test로 hard ceiling을 증명해야 한다. + +- signed exact `Content-Length`가 provider/client/proxy 조합에서 강제됨; +- browser POST policy의 `content-length-range`; +- controlled ingress proxy가 body를 provider write 전에 제한함. + +그 증거가 없으면 server-mediated upload를 사용한다. Upload 후 HEAD/quarantine/delete는 +publication integrity와 cleanup 수단일 뿐 storage/transfer cost DoS에 대한 admission bound가 +아니다. 사후 검증만 가능한 profile은 별도 R1 soft-limit profile로 낮추고 maximum provider +exposure를 명시한다. + +AWS direct upload와 per-object Object Lock을 조합하는 profile은: + +- retention/legal-hold header를 exact signed condition으로 묶고; +- Object Lock upload에 요구되는 `Content-MD5` 또는 qualified SDK checksum algorithm을 강제하고; +- exact version의 retention/legal-hold를 scoped `GetObjectRetention`/`GetObjectLegalHold` evidence로 + 검증한다. + +이 조합을 구현하지 않으면 bucket-default retention만 사용하거나 named direct+retention profile을 +`UNSUPPORTED`로 둔다. Generic 403을 `HELD`로 매핑하지 않는다. + +### 18.3 POST policy + +Browser POST는 optional capability다. + +Pinned AWS SDK 2.30.0 `S3Presigner`는 이 문서가 요구하는 browser POST-policy signer를 제공한다고 +가정하지 않는다. 별도 audited SigV4 POST policy signer와 golden/security test가 없으면 해당 +provider profile은 `directPost=UNSUPPORTED`다. + +- exact bucket/key; +- content-length-range; +- content type prefix가 아닌 exact/좁은 allowlist; +- checksum; +- encryption; +- success status; +- expiration + +을 policy condition으로 고정한다. Policy/fields도 bearer secret으로 redact한다. + +### 18.4 Completion + +Client가 direct upload 완료 API를 호출하면 application은: + +1. session ID와 operation ID/fingerprint를 확인한다; +2. expected object exact HEAD를 수행한다; +3. size/checksum/encryption/version을 검증한다; +4. required scan을 수행한다; +5. 그 뒤에만 publish 또는 DB pending attachment로 진행한다. + +Completion endpoint를 여러 번 호출해도 same terminal receipt를 반환한다. + +### 18.5 Public signing endpoint + +S3-compatible deployment는 application이 접근하는 internal endpoint와 browser가 접근할 public +signing endpoint가 다를 수 있다. + +Binding은 둘을 분리한다. + +- control endpoint: adapter SDK network target; +- presign endpoint: client가 실제 도달할 authority/scheme; +- approved host/scheme allowlist; +- path-style/virtual-host signing mode; +- proxy forwarded-host를 무조건 신뢰하지 않음. + +Request header나 arbitrary URL로 presign authority를 선택하지 않는다. + +### 18.6 Grant issue, response loss, reissue + +Grant response도 bearer credential delivery이므로 stable session/request와 durable generation을 +가진다. + +```text +SESSION_RESERVED + -> GRANT_PREPARED(generation, constraintsDigest, signingTime, expiry, credentialRevision) + -> GRANT_ISSUED + -> UPLOAD_VERIFICATION_IN_PROGRESS + -> DATA_UPLOADED | EXPIRED | ABORTED +``` + +Rules: + +- grant 발급 자체가 stable grant-operation ID와 fingerprint를 가진다; +- URL을 만들기 전에 constraints와 generation을 CAS로 `GRANT_PREPARED`한다; +- signing 뒤 반환 전에 `GRANT_ISSUED` evidence를 기록하되 URL 자체는 저장/log하지 않는다; +- response loss retry는 same session/fingerprint/generation을 lookup한다; +- frozen signing input과 credential revision으로 byte-identical grant를 안전하게 재생성할 수 있을 + 때만 같은 grant를 반환한다; +- 재생성이 불가능하면 CAS로 새 generation을 발급하며 old URL이 revoke됐다고 가정하지 않는다; +- frozen revision이 없거나 active-generation/exposure limit 때문에 안전한 replay/reissue가 + 불가능하면 `GRANT_REPLAY_UNAVAILABLE`로 fail closed한다; +- old/new generation은 같은 immutable key, exact content identity, hard size, create-only + constraint만 가진다; +- active generation 수와 worst-case expiry/in-flight exposure를 제한한다; +- grant response의 operation ID/fingerprint가 다르면 conflict; +- completion은 어느 generation을 사용했는지 client claim만 믿지 않고 exact object를 검증한다. + +Single direct upload abort/reaper는 모든 issued generation의 expiry + clock skew + qualified +provider in-flight horizon 전에는 terminal delete/absence를 주장하지 않는다. Finite horizon이 +없으면 controlled ingress를 사용하거나 indeterminate로 유지한다. + +Direct download grant는 expected reference lifecycle revision에 대해 `GRANT_ISSUED` record를 +conditional CAS한 시점을 linearization point로 삼는다. + +- retirement가 먼저 linearize되면 CAS가 실패하고 생성한 URL은 폐기하며 응답하지 않는다; +- grant issue가 먼저 linearize되면 이후 retirement가 일어나도 이미 issued URL의 잔여 유효성을 + 인정한다; +- URL response가 network에서 늦게 도착해도 ordering은 control CAS로 판정한다. + +Direct download reissue도 old URL의 expiry 전 유효성을 인정하고 active grant/audit count에 +포함한다. Secret delivery ACK loss를 “발급되지 않음”으로 취급하지 않는다. + +## 19. Multipart protocol + +### 19.1 사용 기준 + +Multipart는 다음 조건에서만 사용한다. + +- object size/profile이 configured threshold 이상; +- provider card가 exact capability를 지원; +- part count/size/concurrency budget이 계산 가능; +- abort/reconciliation/lifecycle backstop이 준비됨. + +Small object를 무조건 multipart로 보내지 않는다. + +### 19.2 Session + +`startMultipart`: + +1. operation/fingerprint reserve; +2. immutable internal key 생성; +3. provider multipart create; +4. provider upload ID를 private encrypted/control record에 저장; +5. opaque session ID와 bounded parameters 반환. + +반환 필드: + +- opaque session ID; +- minimum/maximum part size; +- maximum part count; +- maximum concurrent grants/uploads; +- session expiry; +- required checksum; +- final expected size/digest requirement. + +Provider upload ID를 public API에 노출하지 않는다. + +### 19.3 Part upload + +- part number는 1부터 시작하는 bounded integer; +- final part를 제외한 minimum size 규칙을 provider card가 제공; +- client는 arbitrary key/upload ID를 지정하지 않는다; +- part grant는 exact session/key/part/checksum/exact content length/expiry에 묶인다; +- grant expiry는 session expiry보다 짧거나 같다; +- out-of-order part upload는 허용하되 complete는 consecutive `1..N`을 오름차순으로 고정한다; +- final part만 configured minimum보다 작을 수 있다; +- part별 current grant generation은 하나이며 acknowledge 뒤 신규 grant를 발급하지 않는다; +- same part 재발급은 exact length와 full part digest가 같을 때만 새 generation으로 허용한다; +- prior grant는 revoke됐다고 보지 않으며 expiry + skew + qualified in-flight margin 전에는 새 + generation acknowledge/complete를 허용하지 않거나 새 multipart session으로 교체한다; +- provider가 signed part checksum을 강제하지 못하면 direct multipart R2를 비활성화하거나 + controlled ingress를 사용한다; +- direct mode에서 grant issuance count, declared bytes, active expiry window, worst-case replay + exposure를 tenant/destination/global budget으로 제한한다; +- 하나의 presigned part URL이 expiry 전 반복/concurrent 사용될 수 있으므로 provider-side 실제 + request concurrency/transfer bytes가 bounded됐다고 주장하지 않는다; +- strict transport admission은 revocable controlled ingress 또는 provider-enforced primitive가 + 필요하다. + +Browser direct multipart acknowledgement: + +1. CORS가 exact origin/method/request headers와 필요한 `ETag`/checksum response + `Expose-Headers`만 허용한다. +2. Client는 grant와 함께 받은 acknowledgement nonce, part number, bounded provider ETag/checksum + claim을 server에 보낸다. +3. `acknowledgePart`는 session/grant nonce/expiry/fence/exact part length/checksum을 검증한다. +4. Provider `ListParts` 또는 exact provider evidence로 part 존재와 ETag/checksum을 검증한다. +5. 검증된 provider evidence를 private ledger에 CAS로 기록한다. +6. Server는 provider value를 숨긴 `PartReceiptToken`을 반환한다. + +Forged, stale, wrong-session, superseded-attempt claim은 conflict/security audit이며 ledger에 넣지 +않는다. Complete request는 public provider ETag 목록이 아니라 server가 발급한 opaque part +token만 받는다. + +`COMPLETE_IN_PROGRESS` CAS 전에: + +1. 신규 grant/acknowledge admission을 닫는다. +2. 모든 relevant grant generation의 expiry/in-flight horizon을 만족하거나 controlled ingress + drain evidence를 얻는다. +3. paginated `ListParts`를 다시 읽는다. +4. current ledger revision, ETag, algorithm/type/scope checksum, exact length와 비교한다. +5. 불일치하면 complete하지 않고 re-acknowledge/reconcile한다. + +Complete CAS 뒤 신규 grant/acknowledge는 거부한다. Final object full SHA-256이 expected content +identity와 다르면 publish하지 않고 quarantine한다. + +### 19.4 Complete + +Complete 전: + +- required part numbers의 연속성; +- duplicate/missing part; +- each opaque token/session binding; +- expected total size; +- aggregate checksum policy; +- session expiry/state + +를 검증한다. + +Complete response loss는 “실패했으므로 다시 complete”가 아니다. + +Canonical transition: + +```text +ACCEPTING_PARTS + -> COMPLETE_IN_PROGRESS + pendingEffect.kind = MULTIPART_COMPLETE + certainty = NOT_SENT | SENT | INDETERMINATE + -> COMPLETED +``` + +Indeterminate complete는 `COMPLETE_IN_PROGRESS`와 pending-effect certainty를 유지한 채 exact key +HEAD, multipart state, size/checksum/version을 비교해 +`APPLIED`/`NOT_APPLIED`/계속 `INDETERMINATE`로 resolve한다. + +AWS conditional complete가 concurrent delete/write와 경합해 `409 Conflict`를 반환하면 기존 +upload ID에 complete만 재시도하지 않는다. Official provider semantics가 요구하는 경우 새 +`CreateMultipartUpload`부터 전체 session을 재시작하며, original operation record에는 old session +abort/cleanup과 replacement session link를 보존한다. Conditional complete에서 `404`, `409`, +`412`를 각각 provider/card evidence에 따라 분리한다. + +### 19.5 Abort와 orphan + +- abort admission 전에 새 part grant 발급을 막고 application-known attempt를 fence한다; +- 이미 발급한 presigned part URL과 provider가 수락한 in-flight request는 application fence로 + revoke됐다고 주장하지 않는다; +- application abort는 logical하게 idempotent; +- already completed session을 abort success로 오인하지 않는다; +- abort response loss를 reconcile한다; +- abort record는 `pendingEffect.kind=MULTIPART_ABORT`와 certainty를 유지한다; +- provider가 in-flight part의 late success를 허용하면 `Abort -> paginated ListParts -> 필요 시 + Abort 반복`으로 part가 없음을 확인한다; +- earliest terminal check는 `latest issued grant expiry + qualified clock skew + qualified maximum + provider in-flight/request duration` 뒤다; +- presigned expiry는 request-start admission이며 transfer cutoff가 아니므로 expiry만으로 + in-flight 종료를 증명하지 않는다; +- provider가 maximum in-flight horizon을 증명하지 못하면 controlled/revocable ingress를 + 사용하거나 abort certainty를 `INDETERMINATE`로 유지하고 repeated reaper + lifecycle + backstop만 claim한다; +- `NoSuchUpload`는 exact final object/session/control state와 함께 해석해 completed session을 + aborted로 오인하지 않는다; +- maximum abort attempt/deadline 뒤 empty evidence가 없으면 `MULTIPART_ABORT_INDETERMINATE`로 + 유지한다; +- session TTL 뒤 reaper가 exact control state를 claim한다; +- provider lifecycle의 incomplete-multipart abort rule을 backstop으로 설정한다; +- lifecycle rule만을 유일한 cleanup으로 사용하지 않는다; +- reaper와 lifecycle 사이 race를 terminal state/evidence로 처리한다. + +Finite deterministic abort를 R2 profile이 요구하면 위 maximum horizon 또는 revocable controlled +ingress evidence가 필수다. Incomplete-multipart lifecycle age도 이 horizon과 reconciliation +margin보다 길어야 한다. + +### 19.6 Provider limits + +AWS가 제공하는 maximum object/part limits를 portable application default로 그대로 사용하지 않는다. +Destination은 훨씬 낮은 안전한 limit를 고정하고, provider card가 이를 만족하는지만 판정한다. + +## 20. Conditional mutation과 outcome/error + +### 20.1 Mutation outcome + +모든 mutation은 다음 outcome을 사용한다. + +- `APPLIED`; +- `ALREADY_APPLIED`; +- `NOT_APPLIED`; +- `PRECONDITION_FAILED`; +- `INDETERMINATE`; +- `HELD`; +- `UNSUPPORTED`. + +`void`, boolean 한 개, generic success/failure로 축약하지 않는다. + +### 20.2 Preconditions + +지원하는 logical precondition: + +- create if absent; +- operation record state/version equals; +- reference absent; +- reference points to expected object/version; +- object exact version equals; +- retire only published; +- purge only retired and retention elapsed; +- multipart session state equals; +- request fingerprint equals. + +Provider primitive가 이 precondition을 안전하게 구현하지 못하면 해당 card를 R2로 활성화하지 +않는다. check-then-act를 atomic conditional mutation처럼 보고하지 않는다. + +### 20.3 Error taxonomy + +Application-visible typed category: + +- `INVALID_REQUEST`; +- `OBJECT_NOT_FOUND`; +- `OBJECT_NOT_PUBLISHED`; +- `OBJECT_QUARANTINED`; +- `OPERATION_CONFLICT`; +- `OPERATION_EXPIRED`; +- `PRECONDITION_FAILED`; +- `RANGE_NOT_SATISFIABLE`; +- `OBJECT_TOO_LARGE`; +- `METADATA_TOO_LARGE`; +- `MEDIA_TYPE_NOT_ALLOWED`; +- `CHECKSUM_MISMATCH`; +- `RETENTION_HELD`; +- `UNSUPPORTED_CAPABILITY`; +- `POLICY_REVISION_UNAVAILABLE`; +- `BINDING_SECURITY_MISMATCH`; +- `DEPENDENCY_ACCESS_DENIED`; +- `CAPACITY_EXHAUSTED`; +- `THROTTLED`; +- `DEPENDENCY_UNAVAILABLE`; +- `TIMEOUT`; +- `CANCELLED`; +- `CONTENT_PRODUCTION_FAILED`; +- `CONTENT_CONSUMPTION_FAILED`; +- `DIRECT_SESSION_EXPIRED`; +- `GRANT_REPLAY_UNAVAILABLE`; +- `SCAN_INDETERMINATE`; +- `PROVIDER_PROTOCOL_VIOLATION`; +- `PUBLISH_INDETERMINATE`; +- `CORRUPT_CONTROL_RECORD`; +- `UNSUPPORTED_CONTROL_SCHEMA`; +- `INTERNAL_ERROR`. + +각 category는: + +- safe public code/message; +- retryability; +- reconciliation requirement; +- health impact; +- metric outcome; +- audit severity + +를 table-driven mapping으로 가진다. + +### 20.4 Provider error mapping + +AWS/MinIO/filesystem raw exception은 adapter 안에서: + +- operation; +- provider error/status/code; +- bytes sent/received 여부; +- response presence; +- request id의 safe hash; +- exact precondition; +- retry attempt; +- mutation certainty + +를 고려해 normalize한다. + +HTTP 404만 보고 모두 not-found로 매핑하지 않는다. Wrong owner/bucket/permission/endpoint가 +404처럼 보일 수 있는 경우 startup binding과 operation context를 함께 사용한다. + +### 20.5 Retry + +- validation, checksum mismatch, conflict, held는 retry하지 않는다; +- throttling/unavailable은 bounded retry 대상일 수 있다; +- body producer가 non-repeatable이면 upload transport retry를 제한한다; +- immutable create의 response loss는 reconcile 먼저; +- multipart part는 exact part identity/checksum으로 retry 가능 여부를 판정한다; +- complete/delete/reference CAS는 unknown outcome 규칙을 따른다; +- SDK internal retry도 total amplification budget에 포함한다. + +Provider-wide `max-attempts`만으로 mutation 안전을 결정하지 않는다. Put, complete, reference CAS, +delete, abort별 retry policy와 physical attempt telemetry를 고정한다. Mutation에서: + +- SDK가 2회 이상 physical attempt를 수행했거나; +- response body/ack가 유실되었거나; +- AWS `CompleteMultipartUpload`처럼 initial HTTP 200 뒤 embedded error가 가능한 operation이면 + +final SDK exception/success 하나만 보고 certainty를 확정하지 않고 operation-specific evidence와 +reconciliation 규칙을 적용한다. + +## 21. Provider model + +### 21.1 Provider identity + +다음 exact provider type을 사용한다. + +- `filesystem-local-dev`; +- `filesystem-local-persistent` optional; +- `aws-s3-general-purpose`; +- `s3-compatible-minio--`. + +`s3`, `s3-compatible`, `filesystem` 같은 넓은 이름 하나로 production 보장을 선언하지 않는다. + +### 21.2 Capability descriptor + +Provider startup qualification 결과: + +```text +ObjectStorageCapabilityDescriptor + providerType + providerVersion + bindingRevision + qualificationTimestamp + evidenceRevision + evidenceExpiresAt + operations { + managedUpload: CapabilityEvidence + managedDownload: CapabilityEvidence + rangeRead: CapabilityEvidence + controlPlaneCas: CapabilityEvidence + directPut: CapabilityEvidence + directPost: CapabilityEvidence + directGet: CapabilityEvidence + managedMultipart: CapabilityEvidence + directMultipart: CapabilityEvidence + retirement: CapabilityEvidence + exactPurge: CapabilityEvidence + } + guaranteeAxes { + visibility + crashDurability + consistency + mutationOutcomeCertainty + versionIdentity + authoritativeAbsence + } + namedOperationProfiles { + transferMode + checksumAlgorithm/type/scope/encoding + encryptionProfile + retentionProfile + immutableCreate + exactSizeEnforcement + responseLossReconciliation + } + limits + qualificationEvidenceRevision +``` + +`CapabilityEvidence`: + +```text +status = SUPPORTED | UNSUPPORTED | UNVERIFIABLE +source = STATIC_ATTESTATION | STARTUP_PROBE | CI_QUALIFICATION +evidenceDigest +observedAt +validUntil +providerAndDeploymentIdentity +limitations +``` + +Flat boolean의 AND로 조합 capability를 승인하지 않는다. 예를 들어 각각의 checksum, direct +multipart, SSE-KMS, retention이 지원되어도 그 조합이 지원된다는 뜻이 아니다. Destination은 +exact named operation profile을 요구하고 binding compiler는 그 profile 전체에 대한 +`SUPPORTED` + unexpired evidence만 수용한다. + +Direct upload profile이 per-object retention을 요구하면 Object Lock/retention header와 +permission도 signed grant condition에 포함되어야 한다. 그렇지 않은 direct+retention 조합은 +별개 기능이 각각 supported여도 `UNSUPPORTED`다. + +Descriptor 값은 code default가 아니라: + +- provider/version allowlist; +- static deployment attestation; +- safe startup probe; +- integration/qualification evidence + +를 합성한 결과다. + +`UNVERIFIABLE`, expired evidence, guarantee axis가 requirement보다 낮은 상태는 +`SUPPORTED`로 취급하지 않는다. Atomic visibility와 crash durability, consistency와 outcome +certainty를 한 “durable/strong” boolean으로 합치지 않는다. + +### 21.3 Common semantic subset + +Common R2 baseline 후보: + +- private immutable create; +- exact GET/HEAD; +- bounded single range; +- SHA-256 logical digest; +- stable opaque reference; +- control record conditional mutation; +- response-loss reconciliation; +- version-aware retire/purge; +- encryption-at-rest evidence; +- finite timeout/resource budget. + +Provider가 하나라도 증명하지 못하면 destination requirement를 낮춰야 하는 것이 아니라 해당 +provider/destination binding이 startup에서 실패한다. + +### 21.4 Provider-specific capability + +다음은 common subset이 아니다. + +- AWS Object Lock; +- AWS DSSE-KMS; +- provider native checksum 조합; +- MinIO의 specific retention/lifecycle behavior; +- filesystem atomic/durability primitive; +- provider copy; +- provider notification; +- provider replication/region behavior. + +Application이 optional capability를 요구할 때 exact provider card를 통해서만 접근한다. + +## 22. Filesystem providers + +### 22.1 `filesystem-local-dev` + +목적: + +- local sample와 unit/contract test; +- network 없는 개발; +- object/reference/control codec 빠른 검증. + +제한: + +- R0/R1까지만; +- single process/node; +- production profile 금지; +- presign/multipart/KMS/versioning/Object Lock 미지원; +- local disk 소실을 durable storage로 간주하지 않음; +- container image layer 또는 read-only root default 사용 금지. + +활성화하려면 explicit dev/test profile과 absolute configured root가 필요하다. + +### 22.2 `filesystem-local-persistent` + +운영에서 local persistent disk가 정말 요구될 때 별도 card로 qualification한다. + +필수 조건: + +- dedicated mounted volume identity 검증; +- mount missing 시 local directory fallback 금지; +- restrictive root owner/permission; +- no-follow directory traversal; +- exclusive create; +- temp/control record atomic replace; +- file `force`와 directory durability strategy; +- disk/inode/free-space alert; +- quota와 reaper; +- single-node 또는 external fencing 범위 명시; +- backup/restore와 fsck/corruption runbook. + +NFS/shared mount는 이 card에 포함하지 않는다. 필요하면 Fileserver 설계와 별도 provider 설계를 +한다. + +### 22.3 Path safety + +Filesystem key resolution은 string normalize만으로 끝내지 않는다. + +- application raw key 입력 자체를 제거; +- canonical internal segments만 사용; +- root를 startup에서 real path로 pin; +- intermediate symlink/reparse point 거부; +- supported platform에서 `SecureDirectoryStream` 또는 directory-handle-relative operation 사용; +- temp/data/control root 분리; +- root alias와 empty final segment 거부; +- create/delete 시 exact file type과 link count 정책; +- recursive delete 금지; +- cleanup traversal 중 mount/device boundary 정책; +- TOCTOU race fault test. + +지원 플랫폼에서 필요한 safe primitive를 제공하지 않으면 provider card를 낮춘다. + +### 22.4 Write/durability + +Managed write: + +1. target directory 아래 private temp/exclusive file 생성; +2. restrictive permission 적용; +3. bounded stream과 hash; +4. file flush/force; +5. immutable final name으로 no-replace publish; +6. 필요한 directory force; +7. manifest/control CAS; +8. temp cleanup. + +Atomic visibility와 crash durability는 별도 descriptor field다. `ATOMIC_MOVE` 하나로 둘을 모두 +증명하지 않는다. + +### 22.5 Filesystem metadata + +Content type, digest, exact size, generation, state를 filename/xattr에만 의존하지 않는다. Versioned +private manifest를 사용한다. Xattr은 optional evidence일 뿐 portable truth가 아니다. + +## 23. AWS S3 general-purpose provider + +### 23.1 범위 + +초기 AWS card는 regional general-purpose bucket에 한정한다. + +- directory bucket/S3 Express 제외; +- access point/MRAP 제외; +- Requester Pays와 MFA Delete는 initial card에서 제외하고 필요 시 별도 permission/cost/operator + workflow card로 qualification; +- private bucket; +- versioning required 여부는 destination profile에 명시; +- active published object는 online-readable storage-class profile에 고정; +- restore workflow가 없는 initial card는 current data를 archive retrieval이 필요한 tier로 전환하는 + lifecycle을 거부; +- lifecycle, ownership, BPA, encryption은 pre-provisioned; +- runtime은 object/control prefix operation만 수행. + +### 23.2 Consistency + +AWS S3가 현재 제공하는 strong read-after-write/list consistency는 이 exact provider card에만 +적용한다. + +- PUT/DELETE/HEAD/GET 후 object data lookup; +- control record conditional protocol; +- LIST maintenance discovery. + +Bucket configuration, IAM propagation, DNS/network, replication 의미를 같은 consistency로 +확장하지 않는다. MinIO 또는 다른 compatible store로 일반화하지 않는다. + +### 23.3 Client 선택 + +초기 managed streaming transport는 Java-based `S3AsyncClient`를 우선 검토한다. 그러나 R2 +multipart orchestration을 SDK의 opaque automatic multipart에 맡기지 않는다. + +선정 이유: + +- unknown content length streaming 지원; +- standard SDK HTTP/timeouts/retry/metrics와 통합; +- provider client lifecycle을 한 곳에서 소유. + +R2 managed multipart는 adapter가 low-level: + +- `CreateMultipartUpload`; +- `UploadPart`; +- `ListParts`; +- `CompleteMultipartUpload`; +- `AbortMultipartUpload` + +를 직접 호출하고 provider upload ID, part evidence, pending effect, complete/abort certainty를 durable +control record에 보존한다. SDK-managed automatic multipart가 이 evidence/hook를 public하게 +노출하지 않으면 R2 card에 사용할 수 없다. + +Pinned 2.30.0의 automatic multipart는 upload ID/part ledger를 application adapter에 노출하지 +않고 실패 cleanup을 deterministic reconciliation protocol로 제공하지 않는다. 따라서 별도 +qualification 전에는 R1 convenience/transport evidence로만 취급한다. Maximum size와 integer +overflow를 포함한 boundary test 없이 large-object fallback으로 사용하지 않는다. + +CRT-based client는 high-throughput optional card다. 다음을 별도로 qualification한 뒤에만 사용한다. + +- SDK/HTTP configuration 차이; +- retry/timeout 의미; +- metric visibility; +- native library packaging; +- memory/direct-buffer footprint; +- shutdown and cancellation; +- checksum/multipart behavior. + +“더 빠르다”는 이유만으로 baseline을 교체하지 않는다. + +### 23.4 SDK version + +현재 build는 AWS SDK BOM `2.30.0`을 pin한다. 구현 계획은: + +- 승인된 exact version의 API/bug/security evidence를 다시 확인; +- 2.30.0부터 적용되는 default upload checksum calculation behavior를 characterization; +- `requestChecksumCalculation`, `responseChecksumValidation`, explicit algorithm을 provider + profile에 고정; +- AWS/MinIO/presign별 default CRC32와 explicit SHA-256 compatibility를 test; +- pinned 2.30.0 `CompleteMultipartUploadRequest.Builder.mpuObjectSize(Integer)` 경계를 반영해 + FULL_OBJECT multipart checksum profile이 `Integer.MAX_VALUE`를 넘으면 startup에서 거부하거나, + audited raw-header path/승인된 SDK upgrade 뒤에만 허용; +- 2 GiB 경계와 overflow test; +- dependency lock 갱신; +- provider qualification version 기록; +- version upgrade compatibility/fault test; +- deprecated/changed conditional header support 확인 + +을 포함한다. 이 문서가 미래 SDK의 API 존재를 보장하지 않는다. + +### 23.5 Conditional request + +Immutable data/control create에 `If-None-Match: *` 또는 해당 SDK의 exact conditional primitive를 +사용한다. CAS/delete에는 expected ETag/version/precondition을 사용한다. + +- request builder가 header를 제공하는지 exact pinned SDK에서 검증; +- proxy/gateway가 header를 보존하는지 integration test; +- conditional complete의 `404`/`409`/`412`와 transient/permission 오류를 분리; +- AWS가 `409` 뒤 multipart 전체 재시작을 요구하는 operation은 기존 upload ID complete retry + 금지; +- unsupported provider는 check-then-put으로 downgrade하지 않음; +- bucket policy로 conditional write를 강제할 수 있으면 deployment control에 포함. + +AWS bucket policy가 conditional create를 강제할 때 `PutObject`/`CompleteMultipartUpload`에는 +조건을 요구하되, conditional header를 받지 않는 `CreateMultipartUpload`/`UploadPart` 같은 +`s3:ObjectCreationOperation` 단계는 official policy shape에 맞게 exempt한다. 그렇지 않으면 +multipart를 403으로 막을 수 있다. ETag `If-Match` write/delete에 필요한 scoped +`s3:GetObject` permission도 qualification한다. + +### 23.6 Expected bucket owner + +모든 supported request에 expected bucket owner를 설정한다. Startup binding의 account/bucket +attestation과 함께 confused-deputy/misrouting을 줄인다. + +Expected-owner mismatch는 not-found가 아니라 hard configuration/security failure다. + +### 23.7 HEAD, checksum, authoritative absence + +- checksum을 HEAD/GET response로 요구할 때 `ChecksumMode.ENABLED`를 명시한다; +- checksum evidence는 algorithm/type/scope를 함께 읽고 business full digest와 무조건 비교하지 + 않는다; +- SSE-KMS checksum 조회에 필요한 `kms:Decrypt`와 provider 문서가 요구하는 KMS permission을 + qualification한다; +- HEAD 요청에 PUT용 encryption header를 보내지 않는다; +- 403/404만으로 absent, forbidden, wrong owner를 단정하지 않는다. + +AWS `HeadObject`는 missing key에서 caller의 `s3:ListBucket` 권한에 따라 404 또는 403을 반환할 수 +있다. Descriptor에 `authoritativeAbsence` evidence를 둔다. + +- destination prefix로 제한한 `s3:ListBucket`와 version workflow에 필요한 + `s3:ListBucketVersions` 권한으로 negative lookup을 증명하거나; +- absence를 끝까지 `INDETERMINATE`로 유지한다. + +Startup probe는 existing sentinel뿐 아니라 missing-key negative lookup과 forbidden-key +differentiation을 검증한다. MinIO도 exact permission/error behavior를 별도 card로 test한다. + +### 23.8 Encryption + +Destination이 다음 named encryption profile 중 하나를 요구한다. + +- `sse-s3`; +- `sse-kms:`; +- `dsse-kms:` optional. + +Application request에 raw KMS key ARN을 넣지 않는다. Binding compiler가 logical profile을 +pre-approved key와 encryption context로 resolve한다. + +검증: + +- single PUT에는 selected SSE request header; +- multipart에는 `CreateMultipartUpload`에 selected SSE-KMS/DSSE 설정; +- `UploadPart`와 `CompleteMultipartUpload`는 create 설정을 상속하며 SSE-C처럼 동일 KMS request + header를 반복 전송하지 않음; +- part/complete/final response와 HEAD encryption attestation; +- response/head encryption mode; +- KMS key identity/version policy; +- `GenerateDataKey`, `Decrypt` 등 exact operation에 필요한 KMS permission; +- copy 시 source/destination encryption; +- KMS throttling/error mapping; +- presign에 필요한 signed header. + +SSE-C는 baseline에서 제외한다. Key material을 application memory/header/log에 전달하지 않는다. + +### 23.9 Ownership와 public access + +Production requirement: + +- Block Public Access; +- bucket-owner-enforced object ownership; +- ACL disabled; +- public bucket policy 없음; +- access logging/CloudTrail data event 정책은 risk profile에 따라 활성화; +- runtime principal은 exact bucket/prefix/action 최소 권한; +- maintenance principal은 runtime principal과 분리 가능; +- public delivery는 application authorization + stream/presign만 사용. + +### 23.10 Versioning + +Versioning이 required인 destination: + +- startup attestation/probe로 enabled 확인; +- exact version ID를 private evidence에 보존; +- read/retire/purge가 version-aware; +- versioned read/purge role에 scoped `s3:GetObjectVersion`/`s3:DeleteObjectVersion` permission; +- delete marker와 object version을 구분; +- lifecycle noncurrent-version retention을 검증; +- suspended 상태를 enabled로 간주하지 않음. + +AWS bucket에 versioning을 처음 enable한 직후의 propagation window는 `enabled` 조회 한 번으로 +readiness를 승인하지 않는다. IaC attestation에 activation timestamp를 넣고 provider가 권고한 +soak 기간 뒤 sentinel create/read/delete/version test를 통과해야 신규 admission을 연다. + +### 23.11 Retention/Object Lock + +Object Lock card는: + +- exact provider/version의 enablement constraint와 irreversible setting; +- governance/compliance mode; +- default retention; +- legal hold permission; +- versioning; +- bypass-governance 권한 부재 또는 엄격한 별도 break-glass; +- clock/reference time; +- audit + +을 qualification한다. + +AWS general-purpose bucket은 current provider semantics에 따라 existing bucket enablement를 별도 +qualification한다. Older MinIO/other distribution의 creation-time-only 제약을 AWS에 +일반화하거나, 반대로 current AWS 동작을 old MinIO에 일반화하지 않는다. + +Application delete가 hold를 만나면 `HELD`이며 success purge로 보고하지 않는다. + +### 23.12 Lifecycle + +Pre-provisioned lifecycle은 다음을 backstop한다. + +- incomplete multipart expiration; +- noncurrent version retention; +- expired delete marker; +- separately copied immutable terminal-audit archive가 replay/retention horizon을 지난 뒤 만료되는 + narrow prefix. + +Lifecycle만으로 application attachment 상태를 판정하지 않는다. Rule ID와 expected digest를 +deployment attestation에 고정한다. + +Incomplete-multipart lifecycle age는 maximum active session + grant expiry + clock-skew + +reconciliation margin보다 길어야 한다. 정상 장기 upload를 lifecycle이 먼저 abort하지 않는지 +fault test한다. + +Baseline의 published/staged/quarantined data가 같은 immutable `data/v1` prefix를 사용하므로, +provider lifecycle은 control state를 보고 staged object만 안전하게 골라낼 수 없다. 따라서 +staged/quarantined data cleanup은 fenced reconciler만 수행한다. Lifecycle을 여기에 적용하려면 +state-safe immutable prefix 또는 immutable lifecycle marker가 publication과 race 없이 유지된다는 +별도 protocol/fault evidence가 먼저 필요하다. Bucket policy도 lifecycle engine의 잘못된 +deletion을 application state로 막는 대체 수단이 아니다. + +Live/current/non-terminal `control/v1` record에는 provider age-based lifecycle을 적용하지 않는다. +Lifecycle은 pending effect, replay-until, handoff fence, schema, live reference를 이해하지 못한다. +Control cleanup은 fenced reconciler가 exact state와 horizon을 검증해 수행한다. + +## 24. MinIO provider + +### 24.1 Exact qualification + +MinIO는 “S3-compatible”이라는 이유로 AWS card를 상속하지 않는다. + +Card는 최소 다음을 pin한다. + +- exact product/distribution; +- tested server version/range; +- deployment topology; +- Java SDK version; +- versioning; +- retention/Object Lock; +- lifecycle; +- checksum behavior; +- conditional request behavior; +- presign/path-style/virtual-host behavior; +- multipart complete/abort semantics; +- error code mapping; +- consistency/failure assumptions. + +### 24.2 Current Testcontainers evidence + +현재 MinIO integration test는: + +- OSS image `minio/minio:RELEASE.2024-01-16T16-07-38Z`; +- bucket create; +- byte[] put/get; +- exists/delete round trip + +정도의 functional topology 증거다. 이는 R1이며 다음을 증명하지 않는다. + +- streaming heap bound; +- conditional race; +- node/process/network failure; +- response-loss reconciliation; +- multipart orphan; +- versioning/retention; +- TLS/credentials/ownership; +- presigned public endpoint; +- rolling upgrade; +- backup/restore. + +§41의 current MinIO AIStor 문서는 미래 AIStor card를 설계하기 위한 primary reference이며 이 +OSS 2024 image의 동작 증거가 아니다. Current OSS card는 exact image digest, release/source +provenance, AWS-SDK-based contract/fault test로 별도 qualification한다. 현재 adapter는 MinIO +Java SDK가 아니라 AWS SDK를 사용하므로 MinIO Java SDK 문서도 current implementation evidence로 +사용하지 않는다. + +### 24.3 Endpoint + +- production은 HTTPS; +- certificate/hostname verification을 끄지 않는다; +- internal/public presign endpoint를 분리; +- path-style 설정은 exact deployment와 DNS에 맞춤; +- arbitrary endpoint override 금지; +- loopback/RFC1918 endpoint 허용은 explicit environment policy; +- region/signature expectation을 startup에서 검증; +- redirect를 provider equivalence로 따라가지 않는다. + +### 24.4 Feature downgrade 금지 + +MinIO가 특정 checksum/conditional/retention behavior를 지원하지 않으면: + +- 해당 capability evidence를 `UNSUPPORTED` 또는 증거가 불충분하면 `UNVERIFIABLE`로 둔다; +- 필요한 destination binding은 startup 실패; +- AWS semantics를 client-side check-then-act로 흉내 내지 않는다; +- 별도 protocol이 안전하다면 provider-specific 설계와 fault evidence 후 추가한다. + +## 25. Configuration schema + +### 25.1 Canonical prefix + +새 canonical prefix: + +```text +app.object-storage +``` + +기존 `ca-skeleton.objectstorage.*`는 migration 기간 legacy alias로만 탐지한다. + +- canonical과 legacy가 동시에 존재하면 startup 실패; +- legacy를 silent precedence로 덮지 않는다; +- migration warning에 secret/value를 출력하지 않는다; +- removal release를 문서화한다. + +### 25.2 Top-level + +예시: + +```yaml +app: + object-storage: + enabled: false + required-destinations: [] + providers: {} + destinations: {} + maintenance: + enabled: false +``` + +`enabled` default는 `false`다. Provider type과 destination은 default가 없다. + +### 25.3 Provider binding + +개념 예시: + +```yaml +app: + object-storage: + enabled: true + providers: + poster-s3: + type: aws-s3-general-purpose + bucket: ${OBJECT_STORAGE_POSTER_BUCKET} + region: ${AWS_REGION} + expected-owner: ${OBJECT_STORAGE_EXPECTED_OWNER} + credentials: + mode: default-chain + endpoint: + control: null + public-presign: null + addressing: virtual-hosted + encryption-profiles: + poster-default: + type: sse-kms + key-ref: poster-object-key + timeouts: + api-call: 20s + api-attempt: 8s + connect: 2s + tls-negotiation: 3s + acquire: 1s + read: 10s + write: 10s + pool: + max-concurrency: 64 + max-pending-acquires: 128 + retry: + strategy: standard + max-attempts: 3 +``` + +숫자는 예시이며 performance/fault test 없이 production default로 복사하지 않는다. + +### 25.4 Destination binding + +```yaml +app: + object-storage: + destinations: + poster-image: + provider-ref: poster-s3 + namespace: poster-image-v1 + allowed-operations: + - managed-upload + - server-download + - direct-download + maximum-object-bytes: 10485760 + required-content-digest: sha-256 + publication: + mode: scan-gated-reference + encryption-profile: poster-default + retention-profile: poster-standard + direct-upload-profile: disabled +``` + +실제 business media allowlist와 image decode 정책은 sample application typed settings/policy가 +소유할 수 있다. Adapter destination의 maximum bytes와 checksum/encryption은 infrastructure +safety ceiling이다. 둘 다 존재하면 더 엄격한 값을 적용한다. + +### 25.5 Settings type + +- immutable nested records 또는 constructor-bound settings; +- Bean Validation; +- `Duration`, `DataSize`, typed enum; +- provider별 sealed/validated variant; +- unknown property fail 정책; +- duplicate normalized destination/provider ID 거부; +- secret은 `String` field로 직접 바인딩하지 않고 secret reference/credential provider 사용; +- `toString`, validation message, actuator configprops에서 secret redaction; +- endpoint URI의 scheme/userinfo/query/fragment 검증. + +### 25.6 Invalid configuration + +다음은 startup hard failure다. + +- enabled인데 provider/destination 없음; +- required destination 누락; +- unknown provider type/version; +- destination의 provider ref 누락; +- plaintext production endpoint; +- endpoint에 userinfo/query/fragment; +- static credential pair 일부만 존재; +- production static literal credential; +- AWS provider의 expected owner 누락 또는 MinIO/filesystem provider의 required deployment + identity/mount attestation 누락; +- bucket/base root invalid; +- auto-create production option; +- unsupported capability requirement; +- size/part/retry/timeout가 0, 음수, overflow 또는 전체 budget과 모순; +- same namespace collision; +- public presign host가 allowlist 밖; +- scan-required인데 scan seam 없음; +- retention required인데 provider card 불충족; +- legacy/canonical key 동시 사용. + +## 26. Activation, lifecycle, bootstrap + +### 26.1 Exact activation + +Activation 순서: + +```text +settings bind/validate + -> enabled? + -> provider definitions compile + -> destination references compile + -> required capabilities compare + -> safe qualification/attestation verify + -> clients/presigners create + -> maintenance jobs register + -> readiness card publish +``` + +Disabled일 때: + +- filesystem root 생성 없음; +- AWS credential resolution 없음; +- client/event-loop/thread 없음; +- DNS/network 없음; +- scheduler 없음; +- health indicator 없음; +- warning spam 없음. + +### 26.2 No default provider + +`matchIfMissing=true`를 제거한다. Local development도 명시적으로: + +```yaml +app.object-storage.enabled: true +app.object-storage.providers.local.type: filesystem-local-dev +``` + +를 선택한다. + +### 26.3 Provisioning boundary + +Runtime startup은 다음을 만들거나 바꾸지 않는다. + +- bucket; +- KMS key; +- IAM policy; +- lifecycle; +- versioning; +- Object Lock; +- Block Public Access; +- ownership controls; +- CORS. + +IaC/deployment pipeline이 미리 provision한다. Runtime은 safe read/probe 또는 signed attestation으로 +검증한다. + +### 26.4 Startup qualification + +모든 runtime principal에 broad configuration read 권한을 주지 않는다. 두 mode를 지원한다. + +1. safe probe mode: + - expected owner가 있는 exact bucket/head; + - reserved sentinel prefix에 create/head/get/delete; + - conditional create/CAS; + - checksum/encryption response; + - optional multipart probe; +2. deployment attestation mode: + - IaC가 생성한 canonical capability document; + - bucket/account/region/policy/lifecycle/encryption/versioning digest; + - signer identity와 expiry; + - runtime은 signature/digest와 minimal data-plane probe만 검증. + +Probe object는 dedicated namespace, short TTL, bounded size, audit tag를 사용한다. Production user +namespace를 오염시키지 않는다. + +Attestation은 background에서 expiry 전에 refresh한다. + +- bounded last-known-good grace는 signed policy에 명시된 경우에만 사용; +- grace 중 readiness는 degraded이며 신규 write/direct grant admission을 막을 수 있다; +- expiry 뒤 required destination은 신규 mutation/grant를 fail closed; +- 이미 published exact-version read는 별도 read-only continuity policy와 live safe probe가 + 허용할 때만 유지; +- refresh failure가 current binding으로 silent recompile을 일으키지 않음; +- expiry/refresh/last-known-good 사용을 metric/audit한다. + +### 26.5 Bootstrap dependency + +현재 `app-bootstrap` registry는 objectstorage leaf에 production dependency를 허용하지 않는다. +실제 production runtime에 이 capability를 포함하려면: + +1. use case/runtime owner를 확정한다; +2. registry의 `allowed_dependencies`를 설계 승인 후 갱신한다; +3. Gradle dependency를 추가한다; +4. architecture verification을 통과한다; +5. required destination startup test를 추가한다. + +Classpath scan에 우연히 발견되는 configuration을 composition 근거로 사용하지 않는다. + +### 26.6 Lifecycle + +- client/presigner/event-loop executor ownership 명시; +- Spring context stop 시 새 operation admission 중지; +- grace period 동안 in-flight managed upload/download 완료; +- direct session은 durable state라 process shutdown과 분리; +- grace 초과 operation을 cancellation/indeterminate로 기록; +- SDK client/executor close; +- maintenance lease release; +- shutdown hook 하나에만 의존하지 않음. + +## 27. Security design + +### 27.1 Credential + +Production 우선순위: + +1. workload identity/instance/container role; +2. short-lived assumed role; +3. approved external credential process; +4. static credential은 local/test 전용. + +- default credential chain의 exact allowed source를 environment별로 검토; +- developer credential source가 production에서 우연히 선택되지 않게 한다; +- credential expiration/refresh failure를 metric/readiness에 반영; +- access key ID조차 일반 log에 출력하지 않는다; +- credential provider 객체는 provider configuration이 소유한다. + +### 27.2 Least privilege + +역할 분리 후보: + +- runtime managed transfer; +- presign issuer; +- reconciliation/cleanup; +- scanner read/quarantine; +- deployment qualification; +- break-glass retention administration. + +각 역할은 bucket-wide wildcard 대신 destination prefix와 action을 제한한다. Presign issuer 권한은 +grant 가능한 최대 권한의 상한이다. + +### 27.3 Network + +- TLS 1.2 이상; +- hostname/certificate verification; +- outbound DNS/host/port allowlist; +- VPC endpoint/private network 사용 시 policy와 DNS qualification; +- proxy 사용 시 CONNECT/authority/credential leak 검증; +- endpoint override는 static approved binding만 허용; +- SSRF-style request-controlled host/key/presign authority 금지. + +### 27.4 Content safety + +- extension과 client media type을 신뢰하지 않는다; +- maximum decompressed/archive expansion 같은 business risk는 scanner/application이 제한; +- dangerous format은 quarantine; +- scan engine failure/timeout은 clean이 아니다; +- malicious object는 isolation/retention/audit 정책에 따라 처리; +- public download에 `Content-Disposition`, `X-Content-Type-Options` 등 inbound response 정책 적용; +- active content를 same-origin inline으로 제공하지 않는다. + +### 27.5 Confidentiality + +- 모든 provider object/control record private; +- encryption at rest profile required; +- control record에 secret/presigned URL/PII 최소화; +- sensitive metadata는 application DB 또는 encrypted manifest; +- object/reference/key를 metric tag로 금지; +- debug body logging 금지; +- heap dump/core dump risk와 buffer zeroing 필요성을 data classification별로 검토. + +### 27.6 Audit + +감사 event: + +- operation reserved/terminal; +- direct grant issued/expired; +- scan verdict; +- publication/retirement/purge; +- retention held/break-glass; +- qualification mismatch; +- cleanup decision; +- corruption/indeterminate manual resolution. + +Audit에는 safe hashed operation/reference token, destination, action, actor/correlation, outcome, +policy revision을 기록한다. URL, raw key, filename, content는 기록하지 않는다. + +### 27.7 Threat-control-evidence matrix + +| Threat | Required control | Required evidence | +| --- | --- | --- | +| raw key/path traversal or symlink escape | generated canonical key, handle-relative no-follow filesystem access | property/race/security test | +| overwrite/reused direct URL | immutable key, atomic create-only, bucket policy | concurrent/replay provider test | +| oversized upload cost DoS | provider-enforced hard length or bounded ingress | excess-body security/cost-bound test | +| URL/credential leakage | redaction, short TTL, secret-safe telemetry | captured log/trace/audit negative test | +| malicious active content | quarantine, exact-version scan fence, fail-closed publish | clean/malicious/stale/timeout workflow test | +| confused bucket/account/endpoint | expected owner/deployment identity, endpoint allowlist, TLS | wrong-owner/host/cert startup test | +| checksum substitution | typed full/composite/part checksum, expected SHA-256 | algorithm/scope mismatch test | +| mutation ACK loss | pending effect, phase-specific indeterminate reconciliation | dropped-response fault test | +| DB/object split-brain | pending DB state, outbox handoff fence, explicit abort authorization | crash-at-every-gap test | +| forged multipart part claim | nonce/session/fence, ListParts verification, opaque token | forged/stale/cross-session test | +| cleanup data loss | supported schema, exact version, handoff/retention fence, report-only | cleanup-vs-late-commit fault test | +| privilege/hold bypass | retire/purge port split, least privilege, no default governance bypass | IAM/retention negative test | +| stale policy/route | frozen policy revision, retained route registry, no current fallback | rolling migration/revision removal test | +| body/resource exhaustion | aggregate buffers/concurrency/timeouts/cancellation | heap/direct-memory/FD/slow-peer test | + +## 28. Resource budget, timeout, retry, cancellation + +### 28.1 Budget dimension + +Provider/global/destination/tenant별로 제한한다. + +- maximum object bytes; +- maximum delivered range bytes; +- chunk/buffer bytes; +- concurrent managed uploads/downloads; +- concurrent multipart sessions; +- parts per session; +- concurrent parts per session; +- total in-flight part bytes; +- HTTP connection concurrency; +- pending connection acquire; +- control record size; +- pending operation count; +- cleanup batch size; +- scan backlog; +- presign issue rate. + +`byte[]` 전체 materialization을 없애도 concurrent buffer 곱이 heap/direct-memory를 초과할 수 +있으므로 aggregate budget test가 필요하다. + +Managed/server-mediated path는 actual in-flight resource를 admission control한다. Direct presigned +path는 grant issuance와 worst-case replay exposure만 제어하며 provider-side actual request +concurrency/bytes를 strict bound했다고 주장하지 않는다. + +### 28.2 Deadline decomposition + +하나의 “timeout” 필드로 합치지 않는다. + +- application operation deadline; +- connection acquire; +- DNS/connect; +- TLS negotiation; +- SDK API call; +- SDK API attempt; +- socket read/write idle; +- producer/consumer stall; +- scan; +- reconciliation; +- graceful shutdown; +- presign/session expiry. + +Child timeout의 합과 retry backoff가 parent deadline을 넘지 않게 compile한다. + +### 28.3 Retry amplification + +최악의 physical attempt: + +```text +application retry + x operation-kernel retry + x SDK retry + x multipart part count + x concurrent workers +``` + +Binding compiler가 최대 증폭을 계산하고 upper bound를 넘으면 startup 실패시킨다. Metric은 +logical operation과 physical SDK attempt를 분리한다. + +### 28.4 Backpressure + +- bounded executor/queue; +- semaphore admission; +- connection acquire queue 상한; +- multipart concurrency 상한; +- producer가 sink보다 빠르게 무한 buffer하지 않음; +- consumer가 느리면 provider read를 bounded 방식으로 늦춤; +- overload는 `CAPACITY_EXHAUSTED`/429·503 mapping; +- admission 거부가 thread starvation보다 먼저 발생. + +### 28.5 Cancellation + +Cancellation point: + +- before producer start; +- between chunks; +- while SDK future/HTTP body active; +- between multipart parts; +- scan wait; +- reconciliation wait. + +Cancellation 뒤: + +- provider request cancel/response body close; +- partial single upload/multipart abort; +- operation state persist; +- resources/semaphore release; +- caller에게 `CANCELLED` 또는 mutation certainty에 따른 `INDETERMINATE`; +- cancellation을 success로 기록하지 않음. + +### 28.6 Retryable producer + +Managed upload의 producer를 SDK가 임의로 재호출하게 하지 않는다. + +- single-pass producer는 one logical data production; +- transport retry가 body replay를 요구하면 adapter-owned bounded spool 또는 explicit repeatable + producer factory가 있어야 한다; +- spool은 private filesystem/object, quota, encryption, cleanup을 갖춘 별도 optional 전략; +- repeatability가 없으면 mutation을 reconcile하거나 new operation을 요구한다; +- input servlet stream을 재사용 가능하다고 가정하지 않는다. + +## 29. Reconciliation, cleanup, LIST + +### 29.1 Reconciler + +Reconciler는: + +- exact operation ID/reference/session으로 lookup; +- state/version CAS lease; +- bounded batch/deadline; +- provider exact HEAD/GET; +- normalized evidence comparison; +- deterministic continuation/compensation; +- terminal receipt restoration; +- audit/metric + +을 수행한다. + +Application retry path의 inline resolve와 background worker가 같은 kernel을 공유한다. + +### 29.2 Candidate discovery + +Discovery source: + +- durable queue/outbox; +- deterministic age-partitioned control prefix; +- bounded LIST with continuation cursor; +- retry/dead-letter registry. + +LIST의 문제: + +- large namespace 비용; +- pagination; +- concurrent add/delete; +- provider별 ordering/consistency; +- permission 제한. + +따라서 LIST는 후보를 놓치지 않도록 반복하는 maintenance 수단이며 단일 object truth가 아니다. + +### 29.3 Cleanup eligibility + +삭제 전 모두 만족: + +- owned namespace; +- supported schema; +- operation terminal/expired 상태; +- minimum age; +- active lease/fence 없음; +- published reference가 가리키지 않음; +- active application handoff claim 없음; +- verified/staged business object이면 application이 DB/outbox를 조회한 뒤 발급한 exact + `ObjectAbortAuthorization`; +- provider retention/legal hold 없음; +- exact version precondition; +- report-only 결과와 delete 계획 audit. + +Age, LIST 부재, expired worker lease만으로 `ObjectAbortAuthorization`을 대체하지 않는다. +Application 확인이 불가능하면 destructive cleanup을 보류한다. + +### 29.4 Cleanup mode + +- `disabled`; +- `report-only`; +- `delete`. + +Production 첫 활성화는 report-only 기간과 샘플 검토 뒤 delete로 전환한다. Mode 변경은 audit하고 +blast-radius limit를 둔다. + +### 29.5 Unknown/newer object + +Unknown prefix, malformed record, newer schema, missing ownership evidence는: + +- 삭제하지 않는다; +- metric/audit/alert; +- quarantine candidate report; +- manual or upgraded reconciler 대상. + +### 29.6 Notifications + +Provider object event는 reconciliation을 빠르게 하는 hint일 수 있다. + +- event 중복/순서 뒤바뀜/유실을 허용; +- exact state lookup 후 처리; +- event payload를 truth로 사용하지 않음; +- inbound event verification은 inbound/messaging adapter 소유; +- objectstorage outbound leaf가 messaging sibling adapter를 의존하지 않음. + +## 30. Versioning, retention, retirement, purge + +### 30.1 Lifecycle 용어 + +- detach: business entity가 reference를 더 이상 사용하지 않음; +- retire: reference를 download/publication 대상에서 제거; +- delete marker: provider current view 변경; +- purge: exact physical object version 제거; +- expire: policy age 도달; +- held: retention/legal hold로 purge 금지. + +용어를 섞지 않는다. + +### 30.2 Immutable replacement + +Replace는: + +1. 새 immutable object/reference publish; +2. DB CAS로 새 reference 선택; +3. old reference retire; +4. retention 후 old exact version purge. + +같은 key overwrite가 아니다. + +### 30.3 Retention source + +Retention은: + +- business minimum; +- security/quarantine; +- audit/control record; +- provider lifecycle; +- legal hold + +의 합성이다. 가장 긴/강한 requirement를 적용한다. Application이 짧은 TTL을 보내 provider +compliance retention을 줄일 수 없다. + +### 30.4 Physical purge + +- exact object version/token; +- current reference graph 확인; +- retention/hold 조회; +- conditional delete; +- delete response loss reconcile; +- versioned provider에서 delete marker와 version delete 구분; +- manifest/control record tombstone 보존; +- audit/replay/retention horizon 뒤에도 §11.4의 epoch seal/rejection record protocol을 거쳐 + control record를 정리. + +AWS versioned bucket에서 noncurrent version의 exact purge identity는 `versionId`다. `If-Match`가 +current version에 대해 평가되는 의미를 noncurrent-version CAS로 일반화하지 않는다. + +- unversioned/current object: provider가 증명한 ETag `If-Match`와 exact key; +- noncurrent version: exact `versionId`, reference graph/retention fence, provider-specific delete + evidence; +- delete marker: marker version ID를 별도 type으로 구분. + +Provider가 concurrent safety를 증명하지 못하면 purge card를 `UNVERIFIABLE`로 둔다. + +### 30.5 Quarantine retention + +Malicious object는 즉시 public visibility에서 격리하지만 physical delete 시점은 security/audit +policy가 결정한다. Scanner verdict evidence와 content 접근 권한을 최소화한다. + +## 31. Observability + +### 31.1 Metrics + +예시: + +- `object_storage_operation_total{destination,provider,operation,outcome}`; +- `object_storage_operation_duration_seconds{destination,provider,operation}`; +- `object_storage_bytes_total{destination,provider,direction,outcome}`; +- `object_storage_inflight{destination,provider,operation}`; +- `object_storage_sdk_attempt_total{provider,operation,outcome}`; +- `object_storage_reconciliation_total{provider,resolution}`; +- `object_storage_indeterminate_current{destination,provider,operation}`; +- `object_storage_orphan_candidate_current{destination,provider,type}`; +- `object_storage_multipart_session_current{destination,provider,state}`; +- `object_storage_cleanup_total{provider,mode,outcome}`; +- `object_storage_credential_refresh_total{provider,outcome}`; +- `object_storage_capability_mismatch_total{provider,capability}`; +- `object_storage_buffer_bytes{provider,direction}`; +- `object_storage_pool_pending_acquire{provider}`. + +허용 tag: + +- logical destination; +- exact provider type; +- operation family; +- normalized outcome/error; +- capability/card revision의 bounded token. + +금지 tag: + +- bucket/key/path; +- object/reference/operation raw ID; +- tenant/user; +- filename/media metadata; +- endpoint; +- presigned URL; +- provider request ID. + +### 31.2 Trace + +한 logical operation span 아래: + +- binding; +- admission; +- producer/consumer; +- provider attempt; +- integrity verify; +- control transition; +- scan wait; +- reconciliation + +span을 둔다. Content와 URL을 attribute로 넣지 않는다. Provider request ID가 필요하면 bounded hash와 +restricted debug log로만 연결한다. + +### 31.3 Logging + +- terminal transition과 operator action은 structured info/audit; +- transient attempt는 rate-limited debug; +- payload/body, URL, credential, raw key 금지; +- exception message sanitize; +- same operation retry log storm 억제; +- cleanup report는 bounded sample + aggregate count. + +### 31.4 Health + +- liveness는 object storage를 호출하지 않는다; +- readiness는 required destination/card만 평가한다; +- optional destination 장애는 degraded로 노출하되 전체 readiness 정책은 composition에서 결정; +- health request마다 bucket/list/write를 하지 않는다; +- cached qualification + low-rate sentinel probe; +- dependency outage, credential refresh, control corruption, capacity saturation을 구분. + +### 31.5 SLO + +Provider/card별: + +- managed upload success/latency; +- published download success/latency; +- direct completion verification latency; +- indeterminate resolution age; +- orphan backlog age; +- multipart abandon age; +- scan pending age; +- credential expiry horizon + +를 정의한다. Provider SDK success rate만으로 business publication SLO를 계산하지 않는다. + +## 32. Readiness card + +### 32.1 Card ID + +한 module card가 아니라 capability별 card: + +- `object-storage-managed-upload-single`; +- `object-storage-managed-upload-multipart`; +- `object-storage-managed-download`; +- `object-storage-direct-upload-single`; +- `object-storage-direct-upload-multipart`; +- `object-storage-direct-download`; +- `object-storage-quarantine-publication`; +- `object-storage-retention`; +- `object-storage-reconciliation`. + +Registry entry는 exact provider type/version과 destination profile을 dimension으로 가진다. + +### 32.2 R0 + +- application port/value object compile; +- adapter/provider seam compile; +- settings disabled by default; +- no framework/provider type leak; +- unit tests for validation/fingerprint/state. + +### 32.3 R1 + +- local-dev and version-pinned MinIO functional topology; +- bounded managed stream; +- exact head/range; +- checksum happy/mismatch; +- immutable create/conflict; +- control record codec/CAS basic; +- lifecycle/close tests; +- sample consumer contract. + +R1은 production security/failure recovery를 뜻하지 않는다. + +### 32.4 R2 + +모든 R2 card의 공통 evidence: + +- production-like TLS/credential/ownership/encryption; +- startup qualification; +- connection/DNS/TLS/read/write timeout; +- throttling and retry amplification; +- request/response loss; +- process kill/crash recovery; +- rolling schema compatibility; +- observability/redaction; +- no silent test skip. + +Card별 추가 evidence: + +| Card | Required evidence | +| --- | --- | +| managed-upload-single | bounded producer/heap, immutable create, checksum, hard size, single-put response-loss reconciliation | +| managed-upload-multipart | adapter-owned session/upload ID/part ledger, bounded buffers/parts, complete/abort/409/response-loss, orphan cleanup, full digest | +| managed-download | exact published version, full/range read, bounded consumer/heap, truncated/slow/failed consumer, response-body close | +| direct-upload-single | atomic create-only, provider-enforced hard size ceiling, signed checksum/header, expiry/CORS/public endpoint, completion HEAD/verification | +| direct-upload-multipart | session/part ledger, CORS exposed evidence, acknowledge handshake, part budget, complete/abort/409/response-loss, orphan cleanup | +| direct-download | authorization-before-grant, published exact version, expiry/signature-age, response headers, URL redaction/revocation limitation | +| quarantine-publication | unpublished narrow read, scanner fence/policy, clean/malicious/indeterminate, handoff/DB crash gaps, cleanup authorization race | +| retention | versioning, exact version purge, delete marker, lifecycle, retention/legal hold, privileged purge separation | +| reconciliation | pending-effect phase, authoritative absence, conditional race/CAS, process kill, replay tombstone, backup/restore reconciliation | + +Card가 요구하지 않는 multipart, presign, retention evidence를 억지로 요구하지 않는다. 반대로 다른 +card의 evidence를 가져와 해당 card가 준비됐다고 주장하지 않는다. Exact named operation profile +조합이 matrix의 모든 relevant evidence를 만족해야 한다. + +### 32.5 R3 + +- multi-node failover/fencing; +- rolling provider/application upgrade; +- regional/cluster disaster recovery; +- restore ordering and integrity audit; +- sustained scale/soak; +- quota/capacity exhaustion; +- credential/KMS rotation under load; +- provider version upgrade/rollback; +- operational game day and runbook evidence. + +### 32.6 Card claim rule + +Readiness claim은 다음 형태다. + +```text +card + provider exact type/version + destination profile + evidence revision +``` + +예: + +```text +object-storage-managed-upload-single + / aws-s3-general-purpose + / poster-image-v1 + / R2 + / evidence-2026-08-... +``` + +“Objectstorage R2”처럼 범위를 생략한 표현은 금지한다. + +## 33. Test strategy + +### 33.1 Application-core unit/property + +- ID/reference syntax and check digits; +- operation key/epoch seal/rejection/rotation; +- canonical request fingerprint golden vector; +- content digest representation; +- state transition table; +- same operation/same fingerprint; +- same operation/different fingerprint; +- range arithmetic/overflow; +- deadline/budget validation; +- typed error/outcome exhaustiveness; +- framework/AWS type absence. + +### 33.2 Provider-neutral contract + +모든 qualifying provider에 같은 semantic suite: + +- empty/one-byte/chunk-boundary/maximum-size upload; +- immutable create and conflict; +- exact descriptor/version; +- full and range read; +- short/slow/failing producer; +- slow/failing consumer; +- checksum match/mismatch; +- cancel/resource close; +- same operation terminal replay; +- different fingerprint conflict; +- conditional retire/delete; +- indeterminate resolve; +- control schema compatibility. + +Provider unsupported capability test는 silent skip 대신 descriptor와 expected `UNSUPPORTED`를 +검증한다. + +### 33.3 Filesystem + +- traversal/root alias/absolute path/Unicode ambiguity; +- symlink swap and nested symlink; +- exclusive create race; +- process kill before/after force/publish/control CAS; +- disk full, inode exhaustion, permission denied, read-only mount; +- mount identity mismatch; +- cleanup unknown/newer schema; +- file descriptor leak; +- restrictive permission. + +### 33.4 MinIO + +- version-pinned Testcontainers; +- actual streaming; +- conditional create/control CAS; +- multipart part/complete/abort; +- presigned PUT/GET and signed headers; +- grant response-loss/reissue/retirement linearization; +- checksum/head; +- versioning/lifecycle/retention where card claims; +- network cut via Toxiproxy; +- process restart; +- concurrent operations; +- endpoint/path-style/public presign; +- no Docker이면 selected readiness task가 성공으로 끝나지 않음. + +Developer fast test는 Docker 없이 skip할 수 있지만 readiness task는 required environment 부재를 +failure로 처리한다. + +### 33.5 AWS sandbox + +AWS-only R2 evidence: + +- actual account/region/bucket owner; +- TLS/VPC endpoint if used; +- workload role/temporary credential refresh; +- Block Public Access/object ownership; +- SSE-KMS/DSSE profile; +- conditional write/delete; +- strong consistency assumption test boundary; +- versioning/delete marker/noncurrent purge; +- lifecycle/incomplete multipart; +- Object Lock/retention optional card; +- KMS and S3 throttling; +- credential/permission revocation; +- presigned URL expiration and signed checksum; +- request IDs captured safely. + +Sandbox resource provisioning/cleanup은 IaC와 unique namespace를 사용한다. + +### 33.6 Fault/concurrency + +- two writers same operation/same fingerprint; +- same operation/different fingerprint; +- two new operations replacing same attachment; +- DB UploadIntent reserve/pending/abort fence race; +- direct grant response loss, multiple outstanding generations, expiry/in-flight horizon; +- same multipart part reissue, stale late request, acknowledge/complete race; +- operation epoch multi-node cutover/seal/rollback; +- response dropped after put/complete/delete/reference CAS; +- DB crash at every §15 gap; +- worker lease expiry and takeover; +- cleanup versus late finalize; +- retention activated during purge; +- scanner delayed/duplicate/stale verdict; +- rolling old/new control schema. + +### 33.7 Performance/resource + +- object size가 커져도 heap이 size와 선형 증가하지 않음; +- configured chunk/pool/multipart aggregate bound; +- direct memory/FD/thread stability; +- slow producer/consumer; +- connection pool saturation; +- retry storm; +- large concurrent range download; +- graceful shutdown with in-flight operation; +- long soak with cleanup/reconciliation. + +### 33.8 Security/config + +- disabled zero-side-effect context; +- canonical/legacy conflict; +- secret redaction; +- invalid endpoint/owner/credential pair; +- no plaintext production endpoint; +- no auto-create; +- unsupported required capability startup failure; +- presigned URL absent from logs/traces; +- raw key/path/URI absent from public receipt; +- ACL/public access configuration mismatch; +- malicious filename/metadata/control character. + +## 34. Gradle, CI, supply chain + +### 34.1 Focused task + +현재 owner leaf: + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:test --console=plain +``` + +향후 task 후보: + +```text +:adapter:outbound:objectstorage:objectStorageUnitTest +:adapter:outbound:objectstorage:objectStorageFilesystemContractTest +:adapter:outbound:objectstorage:objectStorageMinioContractTest +:adapter:outbound:objectstorage:objectStorageMinioFaultTest +:adapter:outbound:objectstorage:objectStorageAwsQualificationTest +:adapter:outbound:objectstorage:objectStorageSecurityTest +:adapter:outbound:objectstorage:objectStorageResourceTest +``` + +Task 이름과 exact selected provider/card를 machine-readable readiness registry에 연결한다. + +### 34.2 CI lanes + +- PR fast: unit, architecture, filesystem, mocked mapping; +- PR container: MinIO common contract; +- scheduled fault: MinIO/Toxiproxy/process restart; +- protected AWS: sandbox qualification/security; +- release gate: required production cards, schema compatibility, supply-chain scan; +- soak/game day: R3 evidence. + +### 34.3 Architecture verification + +필수: + +```bash +./gradlew verifyCleanArchitectureDependencies +./gradlew test +./gradlew check +``` + +추가 ArchUnit/compile checks: + +- `application-core`에 `software.amazon.awssdk`, Spring, `Path`, inbound type 없음; +- objectstorage leaf가 sibling adapter/sample/bootstrap을 의존하지 않음; +- controller가 repository/SDK/persistence entity를 사용하지 않음; +- raw inbound DTO가 application/domain으로 유출되지 않음. + +### 34.4 Dependency + +- AWS SDK BOM/lock exact pin; +- async HTTP implementation 선택과 transitive dependency review; +- CRT 사용 시 native binary provenance/SBOM; +- CVE/license scan; +- checksum/crypto provider 정책; +- Testcontainers/MinIO image digest pin; +- dependency update 후 provider qualification 재실행; +- unused provider dependency를 runtime image에서 제거할 필요가 생기면 leaf split 검토. + +## 35. Migration plan + +### Phase 0 — Truth and characterization + +변경: + +- 현재 CRUD/overwrite/default activation/sample transaction을 characterization test; +- existing settings/env/runtime usage inventory; +- public `file://`/`s3://` response 소비자 확인; +- current MinIO test를 R1로 명명. + +Acceptance: + +- 현재 동작과 위험이 evidence로 고정; +- production readiness claim 없음. + +Rollback: + +- code behavior change 없음. + +### Phase 1 — Framework-free semantic contract + +변경: + +- ID/reference/version/digest/range/error/outcome; +- streaming callback; +- managed publication/inspection/transfer port; +- existing port는 deprecated compatibility seam; +- default disabled settings skeleton. + +Acceptance: + +- application-core pure unit/architecture test; +- byte[] path를 새 business flow가 사용하지 않음; +- provider type leak 0. + +Rollback: + +- old port consumer 유지, 새 binding disabled. + +### Phase 2 — Provider-neutral kernel and local R1 + +변경: + +- key/reference/control codec; +- operation fingerprint/state machine; +- local-dev provider; +- bounded stream/checksum/immutable create; +- single-node reconciliation; +- disabled zero-side-effect composition. + +Acceptance: + +- provider-neutral contract; +- filesystem security/crash tests; +- R1 card만 게시. + +Rollback: + +- new capability disabled, old local example 유지. + +### Phase 3 — S3 managed transfer common subset + +변경: + +- async S3 client; +- finite timeout/pool/retry; +- streaming put/get/head/range; +- conditional data/control operation; +- checksum/encryption/version evidence; +- exact AWS/MinIO provider package 분리. + +Acceptance: + +- MinIO contract/fault R1; +- AWS sandbox common-subset partial target evidence, Phase 6 전 R2 card claim 금지; +- heap/resource bound. + +Rollback: + +- destination provider binding을 qualified previous provider로 전환; +- operation/reference schema backward-readable. + +### Phase 4 — Direct transfer and multipart + +변경: + +- presigner; +- opaque session/part token; +- multipart ledger/complete/abort/reconcile; +- public signing endpoint; +- browser POST optional. + +Acceptance: + +- URL/header/expiry/redaction; +- complete response-loss; +- orphan cleanup/lifecycle backstop; +- provider/card exact evidence. + +Rollback: + +- direct card disabled; +- managed server upload/download 유지; +- existing sessions drain/expire/reconcile. + +### Phase 5 — Staged scan/publication and sample migration + +변경: + +- staged port; +- scan verdict seam; +- durable reference publish; +- Poster pending/ready/retired state; +- short DB transaction/outbox worker; +- public DTO opaque reference. + +Acceptance: + +- every crash gap test; +- unscanned object inaccessible; +- concurrent replacement deterministic; +- old object retirement; +- no raw locator exposure. + +Rollback: + +- new upload admission 중지; +- pending operation drain/reconcile; +- existing published reference reader 유지; +- old API 제거 전 dual-read compatibility. + +### Phase 6 — Production security and maintenance R2 + +변경: + +- exact settings/qualification; +- workload credential/expected owner/TLS; +- ownership/BPA/encryption/versioning/lifecycle; +- reaper/report-only/delete; +- exact reconciliation card를 위한 disposable namespace bounded backup/restore reconciliation; +- readiness/metrics/audit/runbooks. + +Acceptance: + +- exact required card R2; +- protected fault/security tests; +- cleanup report review; +- no false global claim. + +Rollback: + +- maintenance delete -> report-only; +- provider/card admission off; +- published reads 유지; +- manual reconciliation queue 보존. + +### Phase 7 — R3 and split review + +변경: + +- multi-node fencing; +- rolling upgrade, regional/cluster disaster-recovery game day, restore under failover, scale; +- provider leaf split decision; +- optional retention/direct/high-throughput cards. + +Acceptance: + +- R3 evidence and game day; +- split/no-split ADR; +- restore integrity audit. + +Rollback: + +- optional card disable; +- last qualified provider/schema write version. + +## 36. Existing API compatibility and removal + +### 36.1 Legacy port + +기존 `ObjectStoragePort`는 migration 동안: + +- `@Deprecated`와 explicit legacy name; +- production profile disabled; +- separate legacy root/bucket prefix; +- public URI 반환을 신규 API가 재사용하지 않음; +- usage metric; +- removal deadline + +을 가진다. + +Legacy와 new namespace가 겹치면 startup 실패한다. + +### 36.2 Existing data + +Raw key를 저장한 Poster data migration: + +1. raw key inventory; +2. object exact HEAD/digest/media/size; +3. immutable new reference manifest 생성; +4. DB row를 opaque reference로 CAS migration; +5. dual-read 기간; +6. public response에서 locator 제거; +7. legacy object retirement; +8. unknown/missing/corrupt data report. + +Migration이 existing key를 무조건 rename/copy/delete하지 않는다. Provider/version/retention에 따라 +별도 plan을 생성한다. + +### 36.3 API contract + +기존 upload response의 `key`/`location` 제거는 breaking change다. + +새 response 후보: + +```json +{ + "reference": "osr1....", + "size": 12345, + "mediaType": "image/png", + "digest": { + "algorithm": "SHA-256", + "value": "..." + }, + "state": "READY" +} +``` + +실제 public field/version은 inbound API 설계와 snapshot test로 승인한다. Presigned URL은 별도 +authorization endpoint의 ephemeral response이며 stored object DTO에 영구 포함하지 않는다. + +## 37. 구현 계획 작성 전 확정 항목 + +정본 구현 순서와 아래 결정의 현재 freeze/approval gate는 +[Object Storage Production Capability Implementation Plan](../plans/2026-07-28-objectstorage-production-capability.md)에 +기록한다. 계획 작성은 완료됐지만 모든 구현 task는 아직 미착수이며, 계획 승인이 public API, +scanner provider 또는 AWS/IaC 외부 변경 권한을 자동으로 부여하지 않는다. + +구현 계획은 다음 결정을 task 단위로 명시해야 한다. + +- exact Java type/package 이름; +- old/new port coexistence 기간; +- control record serialization format; +- conditional CAS primitive; +- reference text format과 check digit; +- async S3 HTTP implementation; +- approved AWS SDK version; +- local-dev root; +- exact AWS/MinIO test versions; +- application deadline/cancellation representation; +- scanner capability owner와 port; +- sample Poster schema/outbox migration; +- bootstrap registry edge 필요 여부; +- canonical env/settings/secrets registry 변경; +- machine-readable readiness registry schema; +- cleanup lease/fence; +- API breaking-change versioning. + +이 중 architecture 또는 public contract를 바꾸는 선택은 brainstorming/설계 승인 없이 구현 +task에서 임의 결정하지 않는다. + +## 38. Completion criteria + +### 38.1 Design complete + +- 현재 코드와 sample workflow의 증거가 기록됨; +- alternatives와 selected architecture가 기록됨; +- port/identity/state/protocol/provider/config/security/test/migration 결정이 연결됨; +- official primary references로 변동 가능한 provider 의미가 뒷받침됨; +- 상위 설계에서 dedicated design으로 링크됨; +- 독립 리뷰에서 blocker/high가 해소됨; +- 문서 검증과 LLM Wiki capture 또는 차단 사유가 기록됨. + +### 38.2 Implementation complete + +다음이 모두 있어야 하며 이 문서 작성만으로 충족되지 않는다. + +- approved implementation plan; +- test-first code; +- focused/common/full architecture verification; +- exact provider/card readiness evidence; +- sample workflow migration; +- settings/env/secrets/runtime docs; +- runbook and observability; +- Wiki branch-note; +- human code review. + +### 38.3 R2 complete + +Provider/card별 §32.4 evidence와 production-like qualification이 있어야 한다. MinIO byte[] happy +path, mocked AWS test, filesystem unit test만으로는 R2가 아니다. + +## 39. 금지하는 완료 표현 + +다음 표현은 해당 exact evidence 없이 사용하지 않는다. + +- “ETag은 object MD5다.” +- “presigned URL은 한 번만 쓸 수 있다.” +- “S3-compatible이므로 AWS S3와 동일하다.” +- “PUT이 성공했으므로 DB와 object가 원자적으로 commit됐다.” +- “DB rollback이 object upload도 취소했다.” +- “versioning이 켜져 있으므로 삭제됐다.” +- “delete가 204라 physical version이 사라졌다.” +- “HEAD bucket이 성공했으므로 모든 capability가 준비됐다.” +- “TLS와 SSE를 켰으므로 secure하다.” +- “auto-create가 편리하므로 production에서도 안전하다.” +- “normalize/startsWith로 symlink 공격을 막았다.” +- “MinIO test가 통과했으므로 AWS production ready다.” +- “multipart complete timeout이므로 적용되지 않았다.” +- “retry하면 정확히 한 번 upload된다.” +- “LIST에 없으므로 object가 없다.” +- “scan timeout이므로 clean으로 간주한다.” +- “direct upload 완료 callback을 받았으므로 검증됐다.” +- “objectstorage module이 R2다.” + +## 40. 운영 runbook 요구 + +- required destination startup qualification mismatch; +- wrong bucket/account/region/endpoint; +- credential expiry/refresh/rotation; +- KMS deny/throttle/key disabled; +- TLS/certificate/DNS/VPC endpoint failure; +- connection pool/acquire saturation; +- managed upload/download timeout; +- checksum mismatch/corrupt object; +- scan backlog/scanner outage/malicious verdict; +- publication indeterminate; +- control record corruption/newer schema; +- multipart complete indeterminate; +- abandoned multipart growth; +- orphan/staged/quarantined backlog; +- cleanup report-only에서 delete 전환; +- cleanup wrong-scope kill switch; +- versioning suspended/delete marker growth; +- retention/legal hold; +- presigned URL leak; +- grant response loss/reissue exposure; +- public endpoint/CORS drift; +- MinIO node/restart/upgrade; +- AWS regional/provider outage; +- disk/inode/mount loss for filesystem provider; +- graceful shutdown with in-flight operations; +- database pending attachment backlog; +- UploadIntent/handoff fence stuck or abort race; +- operation epoch rotation/seal/compaction stuck; +- backup/restore 후 reference/data/control reconciliation; +- operation/reference lookup hot partition; +- provider SDK upgrade rollback; +- readiness card downgrade. + +각 runbook은: + +- detection signal; +- safe first action; +- admission/maintenance kill switch; +- evidence collection; +- reconciliation command; +- destructive step의 dry-run/report-only; +- rollback; +- incident/audit link + +를 포함한다. + +## 41. Primary references + +### AWS S3 semantics + +- [Amazon S3 User Guide](https://docs.aws.amazon.com/AmazonS3/latest/userguide/) +- [S3 conditional writes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html) +- [S3 conditional deletes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-deletes.html) +- [DeleteObject API and conditional headers](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) +- [HeadObject API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html) +- [CompleteMultipartUpload API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html) +- [AbortMultipartUpload API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html) +- [Checking object integrity](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html) +- [Multipart upload overview](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html) +- [Multipart upload limits](https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html) +- [Abort a multipart upload](https://docs.aws.amazon.com/AmazonS3/latest/userguide/abort-mpu.html) +- [Abort incomplete multipart uploads with lifecycle](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html) +- [Presigned URL capabilities and limitations](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) +- [Signature V4 POST policy](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-HTTPPOSTConstructPolicy.html) +- [S3 versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) +- [S3 versioning enablement examples and propagation note](https://docs.aws.amazon.com/AmazonS3/latest/userguide/manage-versioning-examples.html) +- [S3 delete markers](https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html) +- [S3 Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html) +- [S3 server-side encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingEncryption.html) +- [S3 SSE-KMS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html) +- [S3 Object Ownership](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html) +- [S3 security best practices](https://docs.aws.amazon.com/AmazonS3/latest/userguide/security-best-practices.html) +- [Expected bucket owner](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-owner-condition.html) +- [S3 network isolation and TLS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/network-isolation.html) + +### AWS SDK for Java 2.x + +- [S3 asynchronous multipart client](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/s3-async-client-multipart.html) +- [S3 client examples and client comparison](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-s3.html) +- [CRT-based S3 client](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/crt-based-s3-client.html) +- [S3 checksums](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/s3-checksums.html) +- [API timeout configuration](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/timeouts.html) +- [Retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html) +- [HTTP client configuration](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/http-configuration.html) +- [SDK metrics](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/metrics.html) +- [SDK troubleshooting](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/troubleshooting.html) +- [SDK best practices](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/best-practices.html) +- [Default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html) + +### MinIO AIStor future-card context and upload security + +다음 MinIO 문서는 current AIStor product용이며 현재 OSS 2024 Testcontainers image의 readiness +evidence로 사용하지 않는다. + +- [MinIO versioning](https://docs.min.io/aistor/administration/objects-and-versioning/versioning/) +- [MinIO object locking and immutability](https://docs.min.io/aistor/administration/object-locking-and-immutability/) +- [MinIO lifecycle rule patterns](https://docs.min.io/aistor/administration/object-lifecycle-management/lifecycle-rule-patterns/) +- [MinIO Java SDK API](https://docs.min.io/aistor/developers/sdk/java/api/) +- [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) diff --git a/docs/superpowers/specs/2026-07-28-production-grade-test-architecture-environment-design.md b/docs/superpowers/specs/2026-07-28-production-grade-test-architecture-environment-design.md new file mode 100644 index 0000000..dbaed90 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-production-grade-test-architecture-environment-design.md @@ -0,0 +1,1154 @@ +# Production-grade test architecture and environment design + +- Status: proposed design; implementation not started +- Date: 2026-07-28 +- Repository baseline: Java 21, Spring Boot 4.0.0, Gradle 9.0.0, 19 registered leaf modules +- Scope: test taxonomy, Clean Architecture boundary ownership, Gradle source sets/tasks, + service provisioning, data isolation, CI/release environments, non-functional qualification, + evidence and rollout +- Out of scope: implementing the suites, choosing an organization's SLO numbers, provisioning + production cloud accounts, or changing the 19-leaf production dependency graph + +## 1. Executive decision + +This repository does not need a larger undifferentiated test pyramid. It needs a **fail-closed +evidence ladder** in which each result says which boundary and environment it actually exercised. + +The proposed destination is: + +```text +source/static evidence + -> deterministic leaf tests + -> real-provider integration tests + -> migration compatibility tests + -> immutable-artifact system/smoke tests + -> scheduled operational qualification + -> post-deploy synthetic evidence +``` + +The core decisions are: + +1. Classify a test on three independent axes: + - **execution boundary**: unit, component/slice, integration, system; + - **verification purpose**: behavior, contract, architecture, migration, security, resilience, + performance, smoke; + - **environment**: in-memory, loopback, disposable container, provider sandbox, deployed + environment. +2. Keep `src/test` deterministic and independent of external infrastructure. It may use an + in-process Spring context or an ephemeral loopback port, but no Docker daemon, Internet service, + manually prestarted database, or fixed host port. +3. Put real PostgreSQL, Redis, MongoDB, Kafka, MinIO, SMTP, and fault-proxy tests in an owning + leaf's `src/integrationTest`. Selecting that lane is a promise to execute it: missing Docker, + zero discovered tests, or an unexpected skip is a failure, not a green build. +4. Add `migrationTest` only where its historical fixtures, destructive lifecycle, and release + frequency differ materially from ordinary integration tests. +5. Reserve `systemTest`/`E2E` for black-box tests of the built boot JAR or OCI image. A + `@WebMvcTest`, mocked repository, or custom test-only boot application is not E2E. +6. Keep tests in their owning registered leaf. Do not add a twentieth production `test-support` + module. Reusable port contract kits use Gradle test fixtures and test-only dependencies. +7. Use explicit Gradle `SourceSet` + `Test` tasks for the first implementation. Gradle's JVM Test + Suite model fits the problem, but remains incubating; this long-lived template favors the stable + mechanism already used by `sampleOffTest`. +8. Use Testcontainers for leaf/provider integration, a CI-specific disposable Compose override for + candidate-image system tests, and protected provider sandboxes only for compatibility claims + that local substitutes cannot establish. +9. Keep `check` Docker-free. CI explicitly fans in deterministic quality, mandatory provider + integration, sample-off, and candidate-image smoke jobs. +10. Treat PR-head artifacts as unpromotable. Reverify the protected main merge commit, build its + boot JAR once, copy that exact JAR into the canonical OCI image, and promote only that image + digest. +11. Do not adopt an arbitrary repository-wide coverage percentage as the definition of quality. + Record coverage first, require explicit risk scenarios, then ratchet changed-code coverage and + use mutation testing selectively for pure domain/application policy. + +## 2. Why this is the right problem + +The repository already has extensive tests, architecture guards, snapshots, Testcontainers, and a +flaky-test quarantine. The problem is not a lack of test classes. The problem is that materially +different evidence is mixed under the same `test` task, while some provider evidence is outside CI +or can disappear through conditional skip. + +A test called `ContractTest` can be a pure port contract, a real PostgreSQL contract, or an HTTP +schema contract. A test called `E2ETest` can still be a MockMvc slice. Names alone therefore cannot +route release evidence. + +The design must answer four questions for every important claim: + +- What production boundary was crossed? +- What was real and what was replaced? +- In which environment did it run? +- What can still be false even when the test passes? + +## 3. Evidence and audit scope + +### 3.1 Repository evidence + +The audit read: + +- `AGENTS.md`, root and leaf `CLAUDE.md` files; +- `src/config/architecture/modules.json`, `src/settings.gradle`, root and leaf Gradle files; +- all `src/test/java` and `src/test/groovy` trees; +- Dockerfiles and Compose files; +- `.github/ci-gate-matrix.yml` and GitHub Actions workflows; +- existing production-capability and CI design documents. + +The inspected worktree was `main` with pre-existing local changes. Those changes were preserved. +Counts below describe that worktree, not a clean historical commit. + +### 3.2 Fresh executable baseline + +The following command was run with task outputs forced to rerun: + +```bash +cd src +./gradlew test --rerun-tasks --console=plain --no-daemon +``` + +Result: + +- build successful in 2m 26s; +- 1,661 JUnit/Jqwik/Spock test invocations; +- 8 skipped invocations; +- 0 failures and 0 errors; +- 275 emitted JUnit XML suites; +- 372 Java test-tree source files and 3 Groovy test-tree source files; +- `domain-core:test` was `NO-SOURCE`; +- the explicit Redis service lane was not part of this result. + +Docker was available during this run, so current PostgreSQL and MinIO tests ran. The successful run +does **not** prove they would fail closed on a runner without Docker. + +### 3.3 Evidence grades used in this design + +| Grade | Meaning | +| --- | --- | +| A | Fresh executable repository evidence or deterministic source/config inspection | +| B | Official product/framework documentation | +| C | Design inference tailored to this repository | +| D | Organization-specific value still requiring an owner decision or measured baseline | + +Provider support versions, CI duration budgets, production SLO thresholds, and cloud topology are +grade D until the adopting service declares them. This design does not fabricate those values. + +## 4. Current state + +### 4.1 Strong foundations to retain + +- Exactly 19 production leaves and allowed dependency edges are fail-closed in + `modules.json`, settings, and Gradle verification. +- ArchUnit has positive controls rather than only vacuous clean checks. +- Core modules use framework-light JUnit/AssertJ dependencies. +- There are MVC, JPA, GraphQL, gRPC, full-context, OpenAPI snapshot, property-based, PostgreSQL, + MinIO, and concurrency tests. +- `sampleOffTest` proves production can be compiled and tested without the example portfolio. +- `quarantineTest` is non-blocking, while registry drift and a 14-day sunset remain blocking. +- Dependency locks, format/static analysis, environment registry, public path snapshot, and + security scanning already feed CI. +- The Docker runtime already has non-root, read-only filesystem, memory, shutdown, and health + contracts that a future artifact lane can exercise. + +### 4.2 Material gaps + +| Gap | Repository evidence | False conclusion it permits | +| --- | --- | --- | +| Mixed execution environments | Unit, slice, Spring context, PostgreSQL, and MinIO tests mostly share `test` | "`test` is fast and hermetic" or "`test` always verifies providers" | +| Conditional provider skip | `disabledWithoutDocker` and `Assumptions.assumeTrue(Docker...)` are present | "CI verified PostgreSQL/MinIO" when it may have skipped | +| Redis lane outside CI | `redisServiceTest` is excluded from default `test`; CI never invokes it | "Redis scripts/TTL work on a real server" | +| Provider tests owned by composition root | Several PostgreSQL semantics tests live under `app-bootstrap` | Leaf capability and cross-leaf wiring evidence are conflated | +| No candidate artifact gate | Local `bootstrapSmoke` exists but is absent from required CI | "The Docker image boots with release configuration" | +| Artifact identity split | `src/Dockerfile` invokes `bootJar` again during image build | "The JAR/class output already tested is byte-identical to the JAR inside the image" | +| Split CI fan-in | `release-gate` can `needs` only same-workflow jobs, while `trivy-fs` runs in `dependency-vulnerability.yml` | "One release-gate currently proves every blocking check across workflows" | +| Non-hermetic local smoke | Persistent DB volume, fixed port 8080, local `.env`, restart policy | Local success is not reproducible CI evidence | +| Misclassified names | A `@WebMvcTest` is named `WorkLogAuthorizationE2ETest` | A slice is mistaken for artifact E2E | +| Incomplete transport evidence | GraphQL tests bypass HTTP; WebSocket tests mock the broadcaster; gRPC has only built-in health/reflection | Feature transport/security/flow-control claims exceed evidence | +| No common test runtime policy | UTC is not applied to every normal `Test`; no repository timeout/parallel policy | Host locale/timezone or leaked state can create flakiness | +| Broad Boot test classpaths | Root adds `spring-boot-starter-test` and `webmvc-test` to every non-core leaf | Outbound leaves see irrelevant web test infrastructure | +| Test dependency graph not governed | Production configurations are checked; test configurations are not equivalently registered | Cross-leaf fixtures or test helpers can silently couple modules | +| Assumption-heavy contracts | About 51 assumption calls across roughly 24 bootstrap test files | Missing paths/resources can be reported as skip rather than failure | +| Java-only quarantine scan | `verifyQuarantineSunset` scans `src/test/java` | A future Groovy quarantined test can evade registry drift checks | +| No coverage/mutation baseline | No JaCoCo or mutation configuration | Untested change risk is not visible, though a percentage alone would not fix it | +| No provider/version/fault matrix | Only selected local substitutes and versions exist | Managed-service, topology, upgrade, or failover readiness is overstated | + +### 4.3 Current test execution by leaf + +The fresh XML baseline was: + +| Leaf | Suites | Invocations | Skipped | Current emphasis | +| --- | ---: | ---: | ---: | --- | +| `domain-core` | 0 | 0 | 0 | Production domain is intentionally skeletal | +| `shared-contract` | 18 | 202 | 0 | Values, metrics, tracing, operation/error contracts | +| `application-core` | 19 | 123 | 0 | Ports, idempotency, outbox, transaction intent | +| `adapter:outbound:support` | 1 | 4 | 0 | Support behavior | +| `adapter:outbound:persistence-jpa` | 11 | 63 | 0 | Mapping, translation, configuration, transaction units | +| `adapter:outbound:persistence-mongo` | 1 | 4 | 0 | Mock-client configuration context | +| `adapter:outbound:identifier` | 2 | 16 | 0 | UUID/HMAC pure behavior | +| `adapter:outbound:fileserver` | 7 | 79 | 0 | Temp-filesystem publication/recovery | +| `adapter:outbound:objectstorage` | 3 | 15 | 0 | Filesystem, mocked S3, MinIO | +| `adapter:outbound:cache-redis` | 12 | 49 | 0 | Fake/runtime/settings; real Redis excluded | +| `adapter:outbound:httpclient` | 17 | 140 | 0 | Loopback/fake resilience and lifecycle | +| `adapter:outbound:messaging` | 6 | 20 | 0 | Fake publisher/settings/log contracts | +| `adapter:outbound:notification` | 2 | 19 | 0 | Routing/provider fake | +| `adapter:inbound:web` | 35 | 180 | 0 | MVC/standalone transport policies | +| `adapter:inbound:grpc` | 4 | 18 | 0 | Mapping plus real loopback health/reflection | +| `adapter:inbound:graphql` | 2 | 14 | 0 | Execution service/schema, no HTTP | +| `adapter:inbound:websocket` | 1 | 2 | 0 | Broadcaster with mocked messaging template | +| `app-bootstrap` | 91 | 537 | 8 | Architecture, settings, contracts, PostgreSQL integration | +| `sample-portfolio` | 43 | 176 | 0 | Reference vertical slice and real PostgreSQL | + +The eight fresh skips were optional-adapter conditional-execution meta-tests and a sample-off-only +assertion. This is why the new policy must reject **unexpected** skips per required suite rather +than naïvely requiring zero skips repository-wide. + +## 5. Taxonomy: execution boundary and purpose are different axes + +### 5.1 Execution boundary + +| Boundary | Real collaborators | Typical location | Expected runtime | +| --- | --- | --- | --- | +| Unit | One object; hand values/fakes only | `src/test` | milliseconds | +| Component | One use case or adapter plus in-process collaborators/ephemeral loopback | `src/test` | milliseconds to seconds | +| Spring slice | Selected auto-configuration and boundary components | `src/test` | seconds | +| Provider integration | Production adapter plus real disposable service/driver/protocol | `src/integrationTest` | seconds to minutes | +| Migration integration | Historical schema/data plus production migration engine and DB | `src/migrationTest` when needed | minutes | +| System | Built boot JAR/OCI image reached only through public ports | `src/systemTest` or external harness | minutes | +| Provider qualification | Candidate adapter against protected real provider/topology | protected scheduled job | minutes to hours | +| Post-deploy synthetic | Deployed artifact through ingress/control plane | deployment pipeline/monitoring | continuously or per deploy | + +### 5.2 Verification purpose + +`contract`, `architecture`, `security`, `resilience`, `performance`, and `smoke` describe **why** a +test exists, not automatically **how far it runs**. + +Examples: + +- a port contract can run against a hand fake in `test` and against PostgreSQL in + `integrationTest`; +- authorization has domain/application unit tests, MVC slice tests, full filter-chain system tests, + and security DAST; +- resilience includes a pure backoff-policy unit test and a Toxiproxy socket integration test; +- a smoke test can target a loopback embedded server or the candidate image, but only the latter is + artifact smoke. + +### 5.3 Naming policy + +The source set is the execution SSOT; suffixes are human navigation aids. + +| Source set/harness | Naming | +| --- | --- | +| `test` | `*Test`, `*ContractTest`, `*ArchitectureTest`, `*ComponentTest` | +| `integrationTest` | `*IntegrationTest` | +| `migrationTest` | `*MigrationTest` | +| `systemTest` | `*SystemTest` or narrowly `*E2ETest` | +| JMH | `*Benchmark` | + +`IT` is accepted during migration but converges to `IntegrationTest`. `E2E` is forbidden outside +the system source set. Proposed first renames include: + +- `WorkLogAuthorizationE2ETest` → `WorkLogAuthorizationWebMvcContractTest`; +- `VirtualThreadMdcE2ETest` → `VirtualThreadMdcHttpComponentTest`; +- `EnvelopeMetaIntegrationTest` → `EnvelopeMetaComponentTest`; +- `S3ObjectStorageAdapterIT` → `S3ObjectStorageAdapterIntegrationTest`. + +## 6. Confidence ladder and honest limits + +```text +client contract + | + | transport slice/component + v +inbound adapter ------ actual socket only in component/system evidence + | + | DTO -> command/result mapping + v +application use case - fake ports prove policy, not provider behavior + | + | reusable port contract + v +outbound adapter ----- real service only in integration evidence + | + | driver/protocol + v +provider ------------- managed topology only in provider qualification + +composition root + packaged runtime cross the whole vertical path only in system evidence +``` + +| Test type | It can establish | It cannot establish by itself | +| --- | --- | --- | +| Architecture/static | Registered project edges, bytecode imports, package/layer rules, schema/snapshot drift | Reflection/string lookup, runtime bean selection, business correctness | +| Unit | Invariant, state transition, decision table, application sequencing | Spring proxies, transaction semantics, serialization, provider behavior | +| Component/slice | A selected adapter/context, request mapping, validation, error/security mapping | Excluded filters/configuration, real server/network, complete bean graph | +| Provider integration | Driver/protocol, vendor constraint, atomicity, serialization, TTL, basic concurrency | Managed IAM/KMS, multi-AZ failover, real quotas/latency, production data scale | +| Contract | Declared port/schema/consumer examples remain compatible | Undeclared consumers, full workflow, deployment, latency | +| System/E2E | Candidate artifact, runtime configuration, public network path, critical vertical flow | Exhaustive business cases, every fault, production capacity | +| Migration | Empty install and named previous-release upgrade/compatibility paths | All production data distributions, lock duration at full scale, guaranteed downgrade | +| Resilience | Behavior under explicitly injected latency/reset/restart/duplicates | Unknown compound failures or regional disaster | +| Security | Named controls, negative cases, known scanner rules | Absence of vulnerabilities or business-abuse paths | +| Performance | Thresholds for a fixed artifact/workload/environment | Production capacity when the environment or workload differs | +| Smoke | The artifact starts, becomes ready, and serves a few critical probes | Functional completeness or SLO compliance | + +## 7. Clean Architecture ownership by leaf + +| Leaf | Deterministic `test` responsibility | Real integration/qualification responsibility | Passing still does not prove | +| --- | --- | --- | --- | +| `domain-core` | Pure invariant/value/state/event/property tests when behavior exists | None | Persistence, transport, framework behavior | +| `shared-contract` | Framework-free value, redaction, metric, trace, error compatibility | None; each consuming adapter owns serialization compatibility for shared types | External serializer/collector/dashboard/consumer behavior | +| `application-core` | Use cases with hand fakes; authorization, idempotency, transaction intent, compensation | Reusable port contract kit, consumed by adapters | Actual DB/broker/cache transaction or concurrency | +| `adapter:outbound:support` | Deadline/logging/failure helper behavior | Only if a real runtime backend is part of its public capability | Provider-specific semantics | +| `adapter:outbound:persistence-jpa` | Mapper, SQL-state translation, transaction-template and configuration component tests | Real PostgreSQL JPA/query/constraint/lock/outbox/idempotency integration | Managed DB topology and bootstrap wiring | +| `adapter:outbound:persistence-mongo` | Mapping/configuration/codec tests | Real MongoDB indexes, queries and, if claimed, replica-set transactions/change streams | Atlas IAM/control plane, sharding/failover | +| `adapter:outbound:identifier` | Deterministic codecs, vectors, validation, pseudonymization | KMS/provider rotation only when such an adapter exists | Global uniqueness or cryptographic safety from small samples | +| `adapter:outbound:fileserver` | `@TempDir` path safety, journal/recovery, limits, atomic local publication | Real mount permissions, process crash, disk-full; supported filesystem-specific qualification | NFS/cross-node fencing unless explicitly tested | +| `adapter:outbound:objectstorage` | Filesystem and mocked SDK mapping | MinIO protocol baseline; protected AWS sandbox for IAM/versioning/checksum/multipart claims | MinIO equivalence to AWS S3 control plane | +| `adapter:outbound:cache-redis` | Key/codec/program/policy/settings tests | Mandatory real Redis standalone; Sentinel/Cluster/TLS/ACL/restart/eviction only for claimed readiness | HA from a standalone test | +| `adapter:outbound:httpclient` | Request/response mapping, budget, retry/circuit, loopback behavior | TCP/TLS/proxy/DNS/pool/cancellation with real sockets and fault proxy; provider sandbox where needed | External provider semantics from a mock server | +| `adapter:outbound:messaging` | Envelope, routing, disabled mode, fake publisher | Real broker ack, partition ordering, duplicate/redelivery, DLT and schema compatibility | End-to-end consumer processing if no inbound consumer exists | +| `adapter:outbound:notification` | Routing/template/provider mapping with fake client | SMTP/webhook sandbox, throttling, timeout, retry and receipt mapping | Deliverability or provider reputation | +| `adapter:inbound:web` | DTO, validation, mapper, error envelope, MVC/security/filter slice | Real embedded-server component tests for servlet/network behavior | Proxy/TLS/ingress or full application composition | +| `adapter:inbound:grpc` | Status mapping/interceptors; loopback health/reflection | Feature RPC, TLS/mTLS, auth, deadline/cancellation/backpressure | Production load balancer behavior | +| `adapter:inbound:graphql` | Schema/resolver/error/complexity component tests | HTTP/WebSocket transport, auth, subscription, N+1 behavior | Client compatibility not represented by schema checks | +| `adapter:inbound:websocket` | Destination mapping and broadcaster policy | Real handshake, STOMP framing, origin/auth, reconnect, broker relay/backpressure | Durable delivery | +| `app-bootstrap` | Architecture, configuration binding/validation, conditional beans, composition contracts | Full production application with real required providers; packaged artifact smoke/system | Domain correctness | +| `sample-portfolio` | Reference domain/use-case/transport tests and sample isolation | Reference PostgreSQL/full-stack sample acceptance | Any downstream project's domain quality | + +`domain-core` having no tests today is not automatically a defect: its current production content +is mostly marker/port abstraction and the example domain is intentionally isolated in +`sample-portfolio`. The gate is behavioral: when a real invariant or value behavior enters +`domain-core`, a Spring-free test enters with it. + +## 8. Gradle execution model + +### 8.1 Alternatives considered + +| Alternative | Advantages | Failure mode in this repository | Decision | +| --- | --- | --- | --- | +| JUnit tags inside the current `test` source set | Small initial diff; familiar filtering | All dependencies remain visible; an untagged container test silently returns to `test`; environment policy cannot be enforced from the classpath | Reject as the primary boundary; keep tags for orthogonal purpose/resource labels | +| Gradle JVM Test Suite plugin | Declarative suites and useful future model | The API remains incubating and would become a template-wide build convention commitment | Reconsider after one explicit-source-set implementation is stable | +| Explicit `SourceSet` plus typed `Test` tasks | Stable Gradle mechanism; isolates classpaths, discovery, reports, and environment policy | More build code and registry validation are required | **Adopt** | +| A new Gradle `test-support` or `acceptance-test` project | Strong classpath isolation | Creates a twentieth production leaf or ambiguous cross-leaf ownership and weakens the current registry model | Reject | + +The first implementation should use a small convention plugin or root build helper backed by +explicit source sets, not copy-pasted task definitions in 19 leaf builds. It must not add a +production dependency edge to `modules.json`. + +### 8.2 Source layout + +Only source sets that a leaf actually needs are created. + +```text +src// + src/main/java/ production + src/test/java/ deterministic unit/component/slice/architecture + src/testFixtures/java/ optional reusable test-only port contract kit + src/integrationTest/java/ disposable real service/driver/protocol + src/migrationTest/java/ optional historical migration lifecycle + +src/app-bootstrap/ + src/systemTest/java/ optional black-box harness with no production classes + +qa/ + performance/ k6/JMH entrypoints and versioned workloads + security/ DAST configuration and safe target policy + system/ shell/container harness when Java adds no value +``` + +`qa/` is verification infrastructure, not a Gradle production module and not a Clean Architecture +leaf. A Java `systemTest` must deliberately avoid `sourceSets.main.output` on its compile/runtime +classpath; it can know public HTTP/gRPC schemas or generated client contracts, but it must not call +an application bean, repository, controller method, or test-only boot class. + +### 8.3 Task contracts + +| Task | Allowed infrastructure | Failure contract | +| --- | --- | --- | +| `:test` | Heap/in-process resources, `@TempDir`, ephemeral loopback server | Fails on Docker/Internet/manual-service dependency, fixed port, zero discovery where the leaf declares required tests, or ordinary assertion failure | +| `:integrationTest` | Testcontainers with pinned provider images and optional Toxiproxy | Fails before discovery if Docker is unavailable; fails on unexpected skip, zero discovery, leaked container, or provider failure | +| `:migrationTest` | Disposable DB plus checked-in versioned fixtures | Fails on empty install, supported-version upgrade, validation, data invariant, lock/error-budget, or cleanup failure | +| `app-bootstrap:systemTest` | Built candidate JAR/image and disposable external services | Fails if the exact candidate cannot start, become ready, expose its build identity, or complete critical black-box probes | +| root `test` | Lifecycle aggregation only | Depends on every registered deterministic leaf task and remains Docker-free | +| root `integrationTest` | Lifecycle aggregation only | Depends on every suite marked required for the selected profile | +| root `releaseCandidateCheck` | Local/single-runner lifecycle for quality, required integrations, and candidate smoke | Reproduces the repository verification bundle; it cannot aggregate independent CI jobs or workflows | + +The root lifecycle tasks are not themselves `Test` tasks and must not manufacture an empty green +report. Local developers may select a leaf lane, while CI selects the registered aggregate. +`releaseCandidateCheck` is a local Gradle lifecycle only. A GitHub Actions `release-gate` becomes +the CI fan-in only after every blocking workflow is exposed through `workflow_call` and invoked as +a job in one caller workflow, because `needs` cannot reference a job in another workflow. Until +that migration is complete, the branch-protection required-check union—not the current +`release-gate` alone—is the enforcement authority. + +### 8.4 Test-suite registry + +`src/config/architecture/modules.json` remains the production dependency SSOT. Add a separate, +fail-closed `src/config/testing/test-suites.json` for verification execution. It references module +IDs from the architecture registry rather than duplicating their paths or Gradle coordinates. + +Conceptual shape: + +```json +{ + "schema_version": 1, + "allowed_test_dependencies": [ + { + "from_module_id": "adapter-outbound-persistence-jpa", + "to_module_id": "application-core", + "scope": "test-fixtures" + } + ], + "providers": { + "postgresql-16": { + "container_image": "postgres:@sha256:", + "topologies": ["standalone"] + } + }, + "suites": [ + { + "id": "persistence-jpa-postgresql", + "owner_module_id": "adapter-outbound-persistence-jpa", + "task": "integrationTest", + "source_set": "integrationTest", + "boundary": "provider-integration", + "purposes": ["contract", "transaction", "migration-baseline"], + "required_services": ["postgresql-16"], + "required_in": ["pull-request", "main-merge"], + "skip_policy": "none" + } + ] +} +``` + +The digest above is intentionally a placeholder, not a proposed image selection. Implementation +resolves and reviews actual supported image tags/digests. A movable tag such as +`postgres:16-alpine` is insufficient release evidence because its bits can change without a source +change. + +`verifyTestSuiteRegistry` must fail when: + +- an owner module is unknown or lacks the declared source root/task; +- a suite ID, provider alias, boundary, purpose, or required cadence is unknown; +- a required suite discovers zero tests or has an unapproved skip; +- a required CI suite has no corresponding job/fan-in entry; +- a registered source set has no registry entry, or a registry entry is never selected; +- production configurations depend on a test fixture; +- test-only cross-leaf dependencies are not explicitly declared and allowed; +- quarantine scanning omits Java/Groovy or a registered source set. + +The suite registry describes execution, not individual test classes. Class discovery counts and +skip outcomes are emitted at runtime and compared with the suite policy. + +The suite registry is the SSOT for suite ownership, test-only edges, and required cadence. The +existing gate matrix remains the SSOT for the complete set of blocking CI jobs, including +non-Gradle and cross-workflow security jobs. CI configuration maps suite IDs to jobs; +`verifyTestSuiteRegistry` checks both directions so neither authority can silently drift. + +### 8.5 Dependency policy for Spring Boot 4 + +Spring Boot 4 modularized starters and test infrastructure. Each leaf declares only the focused +test support it owns—for example, MVC test support for the web leaf and data-JPA test support for +the JPA leaf—instead of the root adding Web MVC test infrastructure to every non-core leaf. The +classic umbrella test starter may remain during migration, but the destination classpath is +capability-specific. + +Baseline rules: + +- `domain-core`: JUnit/Jqwik/AssertJ only as behavior requires; +- `application-core` and `shared-contract`: JUnit/Jqwik/AssertJ and hand-written fakes; +- Spring slices: owning Boot 4 focused test starter only; +- Testcontainers modules and provider drivers: `integrationTestImplementation` only; +- system harness: protocol client/assertion libraries only, never production output; +- strict dependency locks include every new resolvable test configuration. + +This repository is pinned to Spring Boot 4.0.0, whose dependency management currently resolves +JUnit 6.0.1 and Testcontainers 2.0.2. Examples written for Boot 3 or Testcontainers 1.x are not +copied mechanically. A Boot patch uplift is a separate compatibility change with its own evidence. + +## 9. Boundary contracts and reusable fixtures + +### 9.1 Contract-kit pattern + +When `application-core` owns a port, it may publish a test-only abstract contract from +`testFixtures`. Each outbound adapter supplies a factory and runs the same behavioral examples +against its implementation. + +```text +application-core test fixture: CachePortContract + -> in-memory fake contract test + -> Redis adapter deterministic codec test + -> Redis adapter real-provider integration test +``` + +The contract kit should express only application-visible semantics: key validity, idempotency, +not-found behavior, expiry guarantees, error categories, and concurrency expectations that the +port actually promises. It must not import Redis, JPA, HTTP, Spring MVC, or persistence entity +types. + +Provider-specific behavior remains in the adapter suite. For example, a generic repository port +contract does not replace PostgreSQL tests for unique constraints, isolation, SQL-state +translation, locking, or outbox atomicity. + +### 9.2 Contract testing across process boundaries + +Use checked-in schema/snapshot compatibility while this repository is a single template: + +- OpenAPI and GraphQL schema snapshots; +- protobuf descriptor/binary compatibility; +- event schema/envelope compatibility; +- notification/webhook example payloads. + +Serialization tests for a `shared-contract` type live in the consuming web, gRPC, GraphQL, +messaging, or other adapter and use that adapter's production serializer. `shared-contract` stays +framework-free and does not acquire Jackson, Spring, transport, or provider test dependencies. + +Introduce one consumer-driven contract framework only when independently released consumers and +provider verification workflows actually exist. Spring Cloud Contract or Pact can distribute +examples, but neither proves a complete workflow, deployment correctness, authorization, or +provider performance. Running both without distinct consumers would add ceremony rather than +evidence. + +### 9.3 Test-double vocabulary + +Use names according to behavior: + +- **dummy**: required but unused value; +- **stub**: fixed answers; +- **spy**: records interaction; +- **fake**: working but simplified implementation; +- **mock**: expectation-driven interaction verifier; +- **simulator**: protocol-level substitute such as MinIO or a loopback HTTP server. + +Tests and reports must say which substitute was used. Calling MinIO “S3 E2E” or a hand fake “Redis +integration” is prohibited. + +## 10. Environment topology + +### 10.1 Environment matrix + +| Environment | Artifact/target | Data and services | Blocking purpose | Explicit limitation | +| --- | --- | --- | --- | --- | +| Developer/PR deterministic | Compiled classes | Heap, temp directory, ephemeral loopback | Fast behavior, slice, architecture, schema | No real provider or packaged artifact | +| PR provider integration | Compiled owning adapter | Disposable Testcontainers per job/run | Required provider/driver/transaction contract | Local container topology only | +| PR candidate system | Exact boot JAR/OCI digest built once | Disposable CI Compose stack, random ports | Startup, readiness, configuration, critical black-box path | Not managed cloud or production ingress | +| Main/merge candidate | Canonical OCI digest built from the protected merge commit | Required disposable providers plus system stack | Reverify the merged revision and create the only promotable candidate | A passing PR head is not evidence for a different merge commit | +| Nightly compatibility | Same candidate or main artifact | Version/topology matrix, faults, historical migration fixtures | Upgrade, resilience, supported-version breadth | Longer cadence delays feedback | +| Protected provider sandbox | Candidate adapter/application | Real cloud/provider tenant with least privilege | IAM/KMS/TLS/quota/control-plane compatibility | Still not production scale/data/topology | +| Staging/pre-production | Promotable immutable digest | Production-like deployment and sanitized synthetic data | Ingress, rollout, observability, safe DAST/load smoke | Configuration and traffic never perfectly equal production | +| Production | Promoted digest | Production control plane | Read-only/idempotent synthetic and rollback signal | No destructive test, load test, or chaos by default | + +An environment name is not evidence by itself. Each run records artifact digest, source revision, +suite registry version, provider/image versions, topology, feature flags, and sanitized +configuration fingerprint. + +### 10.2 Provisioning rules + +- Testcontainers owns leaf integration dependencies and their lifecycle. +- Spring Boot service connections are preferred when a supported container module exists; a + `GenericContainer` needs an explicit connection name or configuration mapping. +- Containers use exact reviewed tags and, for release evidence, digests. +- Testcontainers reusable-container mode is developer-only: it is experimental and must be off in + CI. +- CI workers use a supported Docker environment. “Docker unavailable” is a preflight failure for + a selected integration job, never a JUnit assumption. +- Compose is reserved for multi-process candidate-image/system evidence, not as the ordinary leaf + test dependency mechanism. +- Provider credentials are short-lived, least-privilege, masked, and issued only to protected + jobs. Fork pull requests cannot reach provider sandboxes. + +## 11. Test data, time, identity, and cleanup + +Every run receives a non-secret `TEST_RUN_ID`; parallel workers additionally include the Gradle +worker ID. That identity scopes every mutable resource: + +| Resource | Isolation key and cleanup | +| --- | --- | +| PostgreSQL | Database or schema per run/worker; explicit truncation or container disposal after commit/concurrency tests | +| MongoDB | Database per run/class; drop on completion | +| Redis | Run-specific key prefix; bounded TTL; never `FLUSHALL` on a shared target | +| Kafka/broker | Unique topic and consumer-group suffix; delete where supported or let disposable broker die | +| S3/MinIO | Bucket or prefix per run; version/delete markers included in cleanup | +| Filesystem | JUnit `@TempDir`; no repository-relative or developer-home mutable paths | +| HTTP/provider sandbox | Idempotency key and tenant/run namespace; compensating cleanup with an audit trail | + +Rules: + +1. Unit/component tests receive an injected fixed `Clock`, seeded random source, and deterministic + identifier generator where time/identity affects behavior. +2. Property-test and randomized-concurrency seeds are printed in XML/log evidence and can be + replayed. +3. Generated credentials exist only for the run, never in source, fixtures, snapshots, command + output, or uploaded logs. +4. Tests never use production data. A release migration rehearsal may use an approved, + de-identified, access-controlled snapshot with owner, retention, deletion, and audit policy. +5. Cleanup runs in `finally`/post-job even after failure. Cleanup failure is visible and does not + erase the original test failure. +6. Polling uses a bounded condition with a diagnostic timeout. `Thread.sleep` is not a + synchronization protocol. +7. A repository test never requires a developer's `.env.local`, persistent Compose volume, or + pre-existing localhost service. + +Rollback is an optimization, not a universal isolation guarantee. Tests that verify commit-time +constraints, listeners, outbox records, retries, concurrent transactions, or a real HTTP server +must commit deliberately and clean their data explicitly. + +## 12. Spring and JUnit execution hazards + +### 12.1 Transaction truth + +- `@SpringBootTest(webEnvironment = RANDOM_PORT)` runs server work on another thread and + transaction; a transaction on the test method does not roll back writes made by the server. +- A default `@DataJpaTest` rollback can hide deferred constraints, commit callbacks, and outbox + behavior. Important JPA tests explicitly `flush`, clear the persistence context, reload state, + and use an explicit commit test where the contract is commit-time. +- Preemptive timeout mechanisms can execute work on a different thread from Spring's + thread-bound test transaction and accidentally commit it. Use framework-aware/non-preemptive + timeouts for transactional tests, and capture a thread dump when a process-level deadline fires. + +### 12.2 Context cache + +Spring's test context cache is static within one JVM and has a finite default maximum. Excess +profiles, unique dynamic property functions, mock-bean declarations, and `@DirtiesContext` create +distinct cache keys or evict contexts. Forked JVMs cannot share the cache. + +The implementation should: + +- define a small named set of test context archetypes; +- reuse configuration and dynamic properties within an archetype; +- prefer hand fakes or explicit test configuration over per-class context mutation; +- report context cache statistics during optimization; +- avoid `@DirtiesContext` unless the test truly corrupts shared context state. + +Context-start failures must fail quickly rather than repeating the same expensive failure across +hundreds of classes. + +### 12.3 Parallel execution + +Parallelism is opt-in by suite: + +- pure unit/property tests may run concurrently after shared-static-state review; +- Spring tests using `@DirtiesContext`, per-test mock-bean mutation, shared database state, or + ordered lifecycle remain sequential; +- Testcontainers' JUnit integration does not promise parallel execution safety, so stateful + provider suites start sequentially and parallelize at the CI job/provider level first; +- JUnit resource locks protect unavoidable JVM-global resources such as timezone, system + properties, and singleton registries; +- all `Test` tasks receive an explicit timezone/locale, bounded task timeout, heap policy, and + deterministic parallel configuration. + +Static Testcontainers fields also need lifecycle review: a container stopped after a class can +leave a cached Spring context pointing at a dead service. Context-managed container beans or +well-scoped shared fixtures are safer when the context is reused. + +## 13. Provider qualification matrix + +The support policy must distinguish a protocol baseline from a production-readiness claim. + +| Capability | Required integration baseline | Conditional qualification for a claimed feature | Not established locally | +| --- | --- | --- | --- | +| PostgreSQL/JPA | Pinned PostgreSQL, Flyway, mappings, constraints, SQL-state translation, transaction/outbox/idempotency, representative query plan | Each supported major upgrade, lock/concurrency and managed-provider TLS/IAM | Multi-AZ failover, production cardinality/IO | +| MongoDB | Pinned real MongoDB, codecs, indexes, queries | Replica set for transactions/change streams; supported upgrade path | Atlas control plane, sharding/failover unless targeted | +| Redis | Pinned standalone Redis, Lua/functions, TTL, serialization, atomicity | ACL/TLS, Sentinel/Cluster, eviction, restart/failover when advertised | HA or cluster safety from standalone | +| HTTP client | Real loopback sockets, TLS fixture, pool/cancellation/deadline, Toxiproxy latency/reset | Corporate proxy/DNS/provider sandbox and supported JDK matrix | External API correctness from WireMock/stub | +| Messaging | Real supported broker, ack, ordering boundary, duplicate/redelivery, retry/DLT, schema | Broker version/topology, auth/TLS, restart/partition fault | End-user completion without a real consumer flow | +| Object storage | MinIO S3 protocol baseline, multipart/checksum/error mapping | AWS sandbox for IAM, KMS, versioning, presigned URL, lifecycle, throttling | AWS control-plane equivalence from MinIO | +| File server | Temp filesystem for path/journal rules | Each supported mount/filesystem, permissions, disk-full, crash recovery, multi-process fencing | Distributed consistency from local disk | +| Notification | Fake routing/template plus local SMTP/webhook receiver | Provider sandbox for auth, throttling, timeout, receipt/webhook mapping | Human inbox placement/deliverability | +| Web | MockMvc slice plus real embedded HTTP component | Proxy headers, TLS, compression, body/connection limits | Ingress/WAF behavior | +| gRPC | Real loopback feature RPC with auth/deadline/cancellation | TLS/mTLS, proxy/load balancer, streaming/backpressure | Mesh/provider behavior | +| GraphQL | Schema/resolver/security/complexity tests | Real HTTP/WebSocket transport, subscriptions, DataLoader query count | Arbitrary client query safety | +| WebSocket | Real handshake/STOMP/origin/auth/reconnect | Broker relay, slow consumer/backpressure, proxy idle timeout | Durable exactly-once delivery | + +This matrix becomes executable only after the adopting project declares which optional capability +and topology it supports. Unclaimed optional features remain documented exclusions rather than +permanently skipped tests. + +## 14. Migration verification + +Database migration evidence has three distinct paths: + +1. **Empty install**: an empty supported database migrates to current and the application starts. +2. **Upgrade**: a checked-in fixture from every supported upgrade baseline migrates to current, + preserves named invariants, and passes Flyway validation. +3. **Compatibility window**: when rolling deployment is supported, old and new application + versions can coexist through the declared expand/contract window. + +Migration fixtures contain structure and synthetic boundary data, not copied production records. +Each fixture declares source application and schema versions, source release artifact digest, +database engine and migration-tool versions, reproducible generation command, fixture and migration +checksums, invariant manifest, and retirement rule. Upgrade fixtures are generated or verified +against the immutable historical release's migration artifacts; a hand-edited dump without that +provenance is not release evidence. + +The release lane also measures migration duration and lock behavior on a representative synthetic +scale. Its threshold is derived from the deployment error budget. A successful small-container +migration cannot establish production lock duration or permit an automatic downgrade. Rollback is +usually application roll-forward plus data repair; destructive database downgrade requires a +separately designed and tested policy. + +`migrationTest` should not be created merely to rename existing JPA integration tests. Add it when +historical fixtures or destructive lifecycle require separate retention, permissions, cadence, or +timeouts. + +## 15. Candidate artifact and system environment + +### 15.1 Build once, test the promotable bits + +For each source revision, CI creates the boot JAR once after the required deterministic and +provider verification for that revision. +The image build copies that exact prebuilt JAR rather than invoking `bootJar` again, records both +SHA-256 digests and their provenance relationship, and treats the OCI digest as the canonical +promotable artifact. System, security, staging, and promotion reuse that digest; a later job must +not rebuild “equivalent” bits. + +The current `src/Dockerfile` runs `bootJar` inside the image build, so this guarantee does not exist +yet. Phase 3 must refactor the Docker build input or explicitly choose an image-only build pipeline +before claiming artifact identity. + +The current local `bootstrap`/`bootstrapSmoke` workflow is useful developer evidence but cannot be +the required system gate unchanged. Add a CI-only `docker-compose.test.yml` or generated override: + +- unique Compose project name derived from `TEST_RUN_ID`; +- generated credentials and random host ports; +- no `.env.local`, developer secrets, named persistent volumes, or restart policy; +- read-only/non-root runtime constraints retained; +- deterministic health/readiness deadlines; +- logs, inspect output, resource usage, and sanitized environment captured before teardown; +- `down --volumes --remove-orphans` in an unconditional cleanup step. + +### 15.2 Minimum black-box probes + +The system harness observes application behavior only through public network interfaces. It may +use the Docker/orchestrator control plane for process lifecycle, signal delivery, dependency fault +injection, digest inspection, and diagnostic collection; it must never call internal beans, +controllers, or repositories. It verifies: + +- process/container starts under production-like profile and filesystem/user constraints; +- liveness and readiness have distinct semantics and readiness waits for required dependencies; +- build revision/image digest and effective non-secret feature profile are observable; +- malformed, unauthenticated, unauthorized, oversized, and unsupported-content requests fail with + the public error contract and no sensitive disclosure; +- graceful shutdown removes readiness first, drains bounded in-flight work, and exits within the + declared platform budget; +- one representative critical flow is exercised for each enabled inbound protocol, with state + verified through a public read path or provider observation rather than an internal repository; +- required migration and dependency-loss behavior match the declared startup/readiness policy. + +The representative flow is not selected by the template in the abstract. An adopting application +must name business-critical journeys and their data cleanup contract. + +## 16. Security, resilience, observability, and performance + +### 16.1 Security + +Security evidence is layered: + +- unit tests for authorization policy and redaction; +- transport slice tests for authentication mapping, CSRF/CORS/origin rules, validation, and error + disclosure; +- system negative tests through the full filter chain and candidate runtime; +- dependency/secret/container/static scanning; +- authenticated DAST against an ephemeral or staging target; +- manual threat-model and abuse-case review for controls scanners cannot infer. + +The control catalog maps to a selected OWASP ASVS version; test techniques may reference OWASP +WSTG. Scanner success is not a proof that the application has no vulnerability. Production +security synthetics are non-destructive and explicitly allowlisted. + +### 16.2 Resilience + +Pure tests verify retry budgets, backoff calculations, idempotency decisions, circuit-state +transitions, and cancellation propagation. Provider integration injects explicit socket latency, +connection reset, timeout, dependency restart, duplicate delivery, and partial response using a +fault proxy or provider control. + +Every scenario asserts both the caller result and bounded side effects: + +- total attempts and elapsed budget; +- no retry of forbidden/non-idempotent operations; +- connection/thread/resource recovery; +- correct metrics/traces/log redaction; +- readiness degradation or continued service according to policy; +- no duplicate durable outcome where idempotency is promised. + +Chaos is not a synonym for randomness. Fault, scope, duration, expected steady state, abort +condition, and cleanup are versioned inputs. Broad production chaos is out of scope until the +organization has an owner and safety process. + +### 16.3 Observability + +Tests use Micrometer's observation test facilities or an in-memory registry to assert semantic +names, low-cardinality tags, error/timeout status, trace propagation, and secret/PII exclusion. +Candidate system tests confirm actuator exposure policy and correlation across a real inbound to +outbound call. + +They cannot establish dashboard correctness, alert routing, collector capacity, or production +cardinality. A staging/post-deploy observability check must inject a known signal and confirm it +reaches the configured backend/alert path. + +### 16.4 Performance + +- JMH is used for microbenchmarks of isolated CPU/allocation-sensitive algorithms only. +- k6 or an equivalent external driver targets the immutable system artifact for latency, + throughput, and error-rate thresholds. +- thresholds are derived from an agreed SLO and workload model, not invented from a shared PR + runner. +- PR may run a small non-gating regression smoke; blocking load/soak runs on controlled, + comparable runners nightly or before release. +- reports record warm-up, JVM flags, CPU/memory limits, dataset/cardinality, concurrency, request + mix, duration, provider topology, and artifact digest. + +JUnit wall-clock assertions on a busy shared runner do not qualify as performance tests. A passing +small load test does not prove maximum production capacity. + +## 17. Determinism, flaky tests, and diagnostics + +The existing 14-day quarantine remains an emergency containment mechanism, not a second backlog. +Extend it to every registered source set and Java/Groovy. A quarantine entry requires owner, +tracking issue, symptom, first/last observed time, deterministic reproduction evidence, and expiry. + +Policy: + +- a required gate never converts an infrastructure error or unexpected skip into quarantine; +- blind auto-retry cannot turn the first failure green; +- one diagnostic rerun may be retained, but the job remains failed and preserves both attempts; +- repeatedly failing tests are fixed or removed only with a replacement evidence argument; +- clock, random seed, port, ordering, locale, timezone, thread scheduling, and external resource + ownership are controlled explicitly; +- process-level timeouts collect thread dump, test task state, container state, and last logs before + termination. + +Each failed provider/system job uploads: + +- JUnit XML and HTML report; +- source revision, artifact and provider image digests; +- suite ID, seed, timezone/locale, Java/Gradle/OS/Docker fingerprint; +- sanitized application/container logs and container inspection; +- thread dump and resource snapshot on hang/timeout; +- migration/provider diagnostics relevant to the owning suite. + +Secrets and payloads are redacted before artifact upload. Retention follows the repository's +security and incident policy. + +## 18. Coverage and test effectiveness + +Add separate JaCoCo execution data and aggregate reports for deterministic and integration lanes. +Do not merge them so early that a provider test hides a missing unit-level decision test. + +Adoption sequence: + +1. publish a baseline without a blocking percentage; +2. inspect packages/classes with meaningful production behavior but no exercised branch; +3. require named risk scenarios for changed domain/application policy and changed adapter + boundaries; +4. introduce a changed-code coverage ratchet once the baseline is stable; +5. apply package-specific floors only when owners understand generated code, DTOs, configuration, + and unavoidable branches; +6. run mutation analysis nightly on pure `domain-core`/`application-core` policy, not on the whole + Spring/container stack. + +Coverage means code was executed; it does not prove the assertion would detect a defect. Mutation +survival is stronger diagnostic evidence but still does not replace missing business scenarios, +contract examples, or production topology tests. + +## 19. CI and release graph + +```text +registry / compile / static / dependency / taxonomy preflight + | + v + deterministic test + architecture + snapshots + | | | + | +--> sample-off --+ + | | + +--> provider integration matrix-+ + v + build main/merge candidate once + | + +-----------------+------------------+ + v v + candidate image smoke migration compatibility + | | + +-----------------+------------------+ + v + GitHub release-gate (caller-workflow fan-in) + | + scheduled/provider/staging qualification + | + immutable promotion + | + post-deploy safe synthetic +``` + +### 19.1 Pull-request blocking lanes + +1. **Preflight/control**: suite/module registries, format/static, dependency locks, environment + keys, taxonomy, quarantine drift, compile. +2. **Deterministic quality**: all leaf `test`, ArchUnit, public-path/schema snapshots, sample-on and + `sampleOffTest`. +3. **Provider matrix**: required PostgreSQL, Redis, MongoDB, broker, and MinIO suites according to + enabled capability registry; jobs parallelize by provider but suites remain fail-closed. +4. **Candidate build**: boot JAR/image plus SBOM/provenance/digest. +5. **Candidate smoke**: disposable Compose and black-box minimum probes. +6. **Gate fan-in**: add a `.github/workflows/ci-release-candidate.yml` caller that invokes + `ci-quality-gates.yml` through `workflow_call` as one job and + `dependency-vulnerability.yml`—including `trivy-fs`—as another reusable-workflow job, + then uses `needs` from its `release-gate` to those jobs and the candidate jobs in that caller. + The gate matrix remains the complete blocking-check inventory; its validator fails when the + caller mapping, reusable workflow, or required-check identity is missing or renamed. + +Do not add path-based job skipping initially. This repository is small enough that correctness of +the evidence graph is more valuable. Optimize only from measured duration/cache data and keep a +periodic full run. + +PR artifacts are diagnostic and unpromotable. A protected main/merge lane reruns all required +deterministic and provider suites against the actual merge commit, builds the canonical JAR/image +once, runs migration and candidate smoke against that digest, and retains its provenance. Only +that merge-commit candidate can advance to staging or release. + +### 19.2 Main/merge candidate lane + +1. Revalidate registries, dependency locks, deterministic tests, and required provider suites on + the protected merge commit. +2. Build the preverified JAR once, copy it into the OCI image, and publish immutable provenance. +3. Run migration compatibility and black-box system smoke against the published digest. +4. Let the caller workflow's `release-gate` fan in every required reusable/candidate job; retain + the successful digest as the only promotable candidate. + +### 19.3 Scheduled/release lanes + +- supported provider and version topology matrix; +- historical migration and rolling-compatibility tests; +- fault/restart/network resilience; +- authenticated DAST; +- controlled load, soak, and resource-leak tests; +- protected real-provider sandbox qualification; +- optional mutation analysis and dependency upgrade compatibility. + +Scheduled failure creates an owned signal and blocks release according to capability policy; it is +not an informational dashboard that can remain red indefinitely. + +GitHub Actions service containers are acceptable for job-level utilities, but Testcontainers +remains the leaf integration mechanism because lifecycle, network endpoint, and image selection +stay close to the test. The candidate application itself is tested as an image in the system lane. + +## 20. Evidence ledger and claim discipline + +Every externally meaningful capability should have a short ledger entry in generated test +documentation: + +| Field | Example kind of value | +| --- | --- | +| Claim | “Repository save and idempotency are atomic on supported PostgreSQL” | +| Owner | `persistence-jpa` | +| Evidence suites | unit port contract, PostgreSQL integration, migration path, candidate smoke | +| Real/replaced | real PostgreSQL; application transport may be replaced in leaf integration | +| Environment/version | image digest/topology or provider sandbox identifier | +| Last result/artifact | CI run and immutable report link | +| Known exclusions | managed failover, production cardinality | +| Expiry/requalification | provider/app version or time-based trigger | + +The following phrases are forbidden unless the corresponding evidence exists: + +- “E2E tested” for a slice, mocked port, or test-only application; +- “production-ready Redis” after only a fake or standalone path when Cluster/Sentinel is claimed; +- “S3 compatible” from SDK mocks alone, or “AWS verified” from MinIO; +- “migration safe” after only empty-database startup; +- “performance proven” without an artifact, workload, controlled environment, and threshold; +- “secure” because scanners are green; +- “all tests passed” when a selected required suite skipped, discovered zero tests, or did not run. + +## 21. Incremental rollout + +### Phase 0 — classify and freeze the baseline + +- approve this taxonomy and capability/support claims; +- add the test-suite registry schema and verification task; +- record current task/class/discovery/skip/duration/context-cache baseline; +- rename misleading `E2E`/`IT` classes without changing behavior; +- declare intentional skip reasons and owners. + +Exit: every current suite is assigned an owner, boundary, environment, purpose, and CI policy. + +### Phase 1 — separate deterministic and provider lanes + +- create explicit `integrationTest` convention/source set; +- move existing PostgreSQL and MinIO tests without changing assertions; +- migrate the Redis real-service lane into the same model and make it CI-required when Redis is an + enabled capability; +- remove Docker assumptions/`disabledWithoutDocker` from required provider suites; +- make `test` Docker-free and add discovery/skip enforcement; +- update strict dependency locks. + +Exit: `test` succeeds on a runner with no Docker, while selected `integrationTest` fails preflight +without Docker and executes real providers when Docker exists. + +### Phase 2 — close capability gaps + +- move provider semantics from `app-bootstrap` to owning leaves; +- add real MongoDB/broker and missing feature-transport integrations for enabled capabilities; +- create application-owned reusable port contract fixtures; +- add provider/version/topology policy and fault cases; +- keep composition-only checks in `app-bootstrap`. + +Exit: each enabled production capability has deterministic contract evidence and at least its +declared provider baseline. + +### Phase 3 — immutable candidate system gate + +- build candidate once; +- add disposable CI Compose/system harness and production-like health/readiness/shutdown checks; +- name critical reference/sample journeys; +- feed all blocking jobs into the existing gate matrix. + +Exit: CI proves the promotable image boots and crosses its declared public/provider boundaries. + +### Phase 4 — migration and non-functional qualification + +- add historical migration fixtures and rolling compatibility where relevant; +- add security mapping/DAST, Toxiproxy faults, observation assertions, controlled load/soak; +- publish split JaCoCo baseline and targeted mutation reports; +- add protected provider sandboxes only for advertised managed capabilities. + +Exit: release claims have owned, reproducible evidence and explicit exclusions. + +### Phase 5 — measured optimization + +- analyze duration, context-cache churn, container startup, and runner utilization; +- tune job sharding and safe unit parallelism; +- introduce changed-code coverage ratchets and evidence expiry; +- consider Gradle JVM Test Suite adoption only if it materially simplifies the proven model. + +Exit: optimization preserves the fail-closed evidence graph and is backed by before/after data. + +## 22. Acceptance criteria + +The test-environment implementation is complete only when all of the following hold: + +1. `modules.json` still registers exactly 19 production leaves and no new forbidden production + dependency edge exists. +2. Every test suite is registered to one owning leaf, one execution environment, and at least one + verification purpose. +3. `./gradlew test` is deterministic and succeeds without Docker, Internet, manual services, + `.env.local`, persistent volume, or fixed port. +4. Selecting a required integration suite with Docker unavailable fails before JUnit discovery. +5. Required suites fail on zero discovery and unexpected skip, while explicitly approved + conditional/meta-test skips remain visible. +6. Testcontainers dependencies are absent from ordinary deterministic source-set classpaths. +7. Provider images/versions/topologies and compatibility exclusions are declared and emitted in + reports. +8. Mutable data is namespaced per run/worker and cleanup is verified after success and failure. +9. Candidate system tests observe application behavior only through public ports; lifecycle and + fault controls stay in the external orchestrator plane. The canonical OCI provenance identifies + the exact prebuilt JAR it contains. +10. Migration evidence includes empty install and every declared upgrade baseline; rolling + compatibility is tested if advertised, and historical fixtures have immutable release + provenance. +11. All blocking work is represented inside one caller as ordinary or reusable-workflow jobs; its + `release-gate` cannot stay green if one is missing or renamed, and the only promotable candidate + was built and reverified from the protected main merge commit. +12. Reports preserve first failure, seed, environment fingerprint, artifact/provider digests, and + sanitized diagnostics. +13. Security, resilience, observability, and performance claims list the specific environment and + exclusions they cover. +14. Test fixtures cannot become production dependencies, and adapter/provider types cannot leak + into core contract kits. +15. Focused leaf verification, aggregate deterministic/integration checks, candidate smoke, and + architecture gates have fresh executable evidence. +16. The LLM Wiki branch note records implementation decisions, commands, failures, and evidence + grade before completion is claimed. + +## 23. Expected implementation impact + +The design anticipates changes in these areas; this document does not yet authorize or implement +them: + +- `src/config/testing/test-suites.json` and its schema; +- `src/build.gradle` or a build-logic convention for source sets/tasks/verification; +- focused leaf Gradle dependencies and lockfiles; +- relocation/rename of existing tests without changing their initial behavior; +- provider fixtures and port contract test fixtures; +- a new `.github/workflows/ci-release-candidate.yml` caller/fan-in workflow; +- `workflow_call` entrypoints in `ci-quality-gates.yml` and `dependency-vulnerability.yml`; +- `.github/ci-gate-matrix.yml` plus its caller/reusable-job mapping validation; +- `src/Dockerfile` or its build context so the image consumes the exact prebuilt boot JAR; +- a CI-only disposable Compose override/system harness; +- `qa/security`, `qa/performance`, and evidence/report publishing; +- contributor documentation for choosing an owner, source set, and local command. + +Explicit non-goals for the first implementation: + +- adding a twentieth production module; +- changing a business port or production dependency direction merely for testing convenience; +- immediately supporting every optional provider topology; +- enforcing an arbitrary global coverage percentage; +- running destructive DAST/load/chaos against production; +- using cloud credentials in untrusted pull-request jobs; +- replacing focused leaf tests with a single slow system suite. + +## 24. Owner decisions required before implementation + +The architecture can be implemented incrementally, but these values cannot be derived honestly +from the skeleton: + +1. Which adapters are mandatory in the default template CI versus optional capability profiles? +2. Which PostgreSQL, MongoDB, Redis, broker, object-storage, and JDK versions/topologies are + supported? +3. What are the maximum PR and release-lane budgets, runner topology, artifact retention, and + quarantine service-level agreement? +4. Which public journeys are release-critical when the sample portfolio is disabled? +5. Is rolling application/schema compatibility promised, and for how many released versions? +6. Which managed-provider sandboxes exist, who owns cost/credentials/cleanup, and which claims do + they qualify? +7. What SLO/workload/error budget defines readiness, graceful shutdown, migration, and performance + thresholds? +8. Which ASVS level/control set and DAST target policy does the adopting organization require? + +Default pending those decisions: + +- all capabilities included in the ordinary production composition are required PR integrations; +- optional/sample-only capabilities are explicitly non-required, not assumption-skipped; +- `test` remains Docker-free and provider jobs fail closed; +- managed topology, production capacity, and rolling compatibility are **not claimed**; +- current CI has no invented numeric performance or duration gate. + +## 25. Official references + +The design is based on repository evidence plus the following primary documentation: + +- [Spring Boot 4.0 migration guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide) + and [test infrastructure modularization rationale](https://spring.io/blog/2025/10/28/modularizing-spring-boot/) + for focused Boot 4 test dependencies. +- [Spring Boot test slices](https://docs.spring.io/spring-boot/4.0/appendix/test-auto-configuration/slices.html), + [application testing](https://docs.spring.io/spring-boot/4.0/reference/testing/spring-boot-applications.html), + and [Testcontainers service connections](https://docs.spring.io/spring-boot/4.0/reference/testing/testcontainers.html). +- [Spring Boot development-time services and Compose](https://docs.spring.io/spring-boot/4.0/reference/features/dev-services.html); + tests do not automatically turn a development Compose workflow into release evidence. +- [Gradle Java testing/source sets/test fixtures](https://docs.gradle.org/current/userguide/java_testing.html), + [JVM Test Suite plugin](https://docs.gradle.org/current/userguide/jvm_test_suite_plugin.html), + [test report aggregation](https://docs.gradle.org/current/userguide/test_report_aggregation_plugin.html), + and [JaCoCo integration](https://docs.gradle.org/current/userguide/jacoco_plugin.html). +- Spring Framework guidance for [context caching](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/caching.html), + [parallel execution](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/parallel-test-execution.html), + [context failure threshold](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/failure-threshold.html), + and [test-managed transactions](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/tx.html). +- [JUnit 6 parallel execution and resource locks](https://docs.junit.org/6.0.1/writing-tests/parallel-execution.html). +- Testcontainers guidance for [JUnit lifecycle and parallel limitations](https://java.testcontainers.org/test_framework_integration/junit_5/), + [experimental reusable containers](https://java.testcontainers.org/features/reuse/), + [Toxiproxy](https://java.testcontainers.org/modules/toxiproxy/), and + [supported Docker environments](https://java.testcontainers.org/supported_docker_environment/). +- [Testcontainers Java 2.0.0 release notes](https://github.com/testcontainers/testcontainers-java/releases/tag/2.0.0). +- Flyway [validation](https://documentation.red-gate.com/flyway/reference/commands/validate) and + [baseline concepts](https://documentation.red-gate.com/flyway/flyway-concepts/baselines). +- [Spring Cloud Contract reference](https://docs.spring.io/spring-cloud-contract/reference/index.html) + for the conditional consumer-driven-contract option. +- [Spring Security servlet testing](https://docs.spring.io/spring-security/reference/servlet/test/index.html), + [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/), and +- [GitHub Actions reusable workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows) + for `workflow_call`, caller jobs, and same-commit reusable workflow invocation. + [OWASP WSTG](https://owasp.org/www-project-web-security-testing-guide/). +- [Micrometer observation testing](https://docs.micrometer.io/micrometer/reference/observation/testing.html), + [k6 thresholds](https://grafana.com/docs/k6/latest/using-k6/thresholds/), and + [OpenJDK JMH](https://openjdk.org/projects/code-tools/jmh/). +- [GitHub Actions service containers](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers). + +Versioned Spring Boot `/4.0/` documentation can reflect a later 4.0.x patch than this repository's +exact 4.0.0 baseline. Any API or dependency not verified against the locked build remains a +proposal until implementation tests it. diff --git a/docs/superpowers/specs/2026-07-28-redis-cache-resilience-design.md b/docs/superpowers/specs/2026-07-28-redis-cache-resilience-design.md new file mode 100644 index 0000000..dcadf83 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-redis-cache-resilience-design.md @@ -0,0 +1,137 @@ +# Redis Cache Resilience Increment Design + +**Status:** approved for implementation + +**Parent:** `2026-07-26-redis-production-capability-design.md` §§15, 16, 18 + +## Goal + +Complete one coherent production-facing cache increment on top of the current standalone R1 Redis +runtime: + +1. a framework-free cache-aside policy in `application-core`; +2. bounded local single-flight and source bulkhead protection; +3. deterministic TTL jitter plus soft/hard expiry and stale lookup semantics in the Redis adapter. + +This increment does not promote Redis beyond standalone cache R1. Distributed refresh leases, +generation invalidation, rate limiting, owner-safe locks, idempotency, sessions, Sentinel/Cluster, +TLS/ACL and fault qualification remain later increments. + +## Architecture boundary + +- `application-core` owns lookup interpretation, source-result classification, cache-aside + sequencing, stale-if-error, local coalescing and source admission policy. +- `adapter:outbound:cache-redis` owns physical TTL, envelope timestamps, deterministic jitter, + serialization and Redis command outcomes. +- The application contract contains no Redis/Lettuce/Lua/Spring type. +- Cache fallback never becomes unlimited source fallback. A miss, provider outage and waiter burst + all pass through the same bounded source path. + +## Application contract + +`CacheSourceLoader` returns a typed `SourceLoadOutcome`: + +- `Loaded(value, sourceRevision)`; +- `AuthoritativeAbsent(reason, sourceRevision)`; +- `TransientFailure(SourceFailure)`; +- `PermanentFailure(SourceFailure)`; +- `Cancelled`. + +`SourceFailure` carries a bounded code and the original cause. It never serializes the cause message +into Redis or metric tags. An unclassified thrown exception is rethrown unchanged and is never +negative-cached or converted to stale success. + +`CacheResult` distinguishes: + +- fresh cache hit; +- source-loaded value and its cache-record outcome; +- authoritative absence and its cache-record outcome; +- stale fallback after a classified transient source failure; +- source failure; +- bounded overload/timeout rejection; +- cancellation. + +`CacheAsidePolicy` is immutable and constructed once per semantic region. It contains maximum +in-flight source keys, waiter limit per key, source concurrency, admission wait, load deadline and +whether transient source failure may serve stale. + +## Cache-aside state machine + +1. `Hit(FRESH)` returns immediately. +2. `NegativeHit` returns immediately. +3. `Hit(STALE)` retains the value and attempts a bounded refresh. +4. `Miss`, an `IncompatibleSchema(QUARANTINE_AND_RELOAD)` carrying a usable opaque observation + token, and `Unavailable` enter the same bounded source path. `FAIL_FAST` schema results and + unobservable incompatible values are not overwritten. +5. A local single-flight elects one leader per semantic key. Waiters share the typed source outcome. +6. The leader must acquire the source bulkhead before calling the loader. +7. A miss records with `ONLY_IF_ABSENT`. A stale or quarantined observation records with + `ONLY_IF_OBSERVED`, which atomically compares the digest captured by lookup before replacing the + value. No lookup-then-delete sequence is used, so a concurrent writer is never deleted. +8. Only `AuthoritativeAbsent` records a negative entry, using the same absent/observed condition as + a positive source result. +9. `TransientFailure` may return the retained stale value when policy allows it. +10. `PermanentFailure`, unclassified exceptions and cancellation are never hidden by negative cache. +11. Entries are removed from the flight map after success or failure. In-flight keys and waiters are + bounded; waiting uses a finite deadline and preserves thread interruption. + +The loader is synchronous and cancellation is cooperative. Its token exposes deadline/interruption; +the executor bounds admission and waiter time but cannot safely terminate arbitrary source code. + +## Redis envelope and TTL policy + +The positive envelope moves to version 2 and stores: + +- source revision; +- `softExpiresAt` epoch milliseconds; +- `hardExpiresAt` epoch milliseconds; +- payload and SHA-256 integrity digest. + +Negative envelopes store only the hard expiry. Lookup behavior is: + +- `now < softExpiresAt`: `Hit(FRESH)`; +- `softExpiresAt <= now < hardExpiresAt`: `Hit(STALE)`; +- `now >= hardExpiresAt`: `Miss(EXPIRED)`; +- negative `now < hardExpiresAt`: `NegativeHit`; +- expired negative: `Miss(EXPIRED)`. + +Version 1 becomes an explicit retired schema result. Future versions and corrupt envelopes fail +fast. Digest-valid retired/unknown envelopes carry an opaque observation token so an approved +quarantine reload can compare-and-replace the exact observation. Structurally invalid current +envelopes remain corrupt/fail-fast even when their digest is valid. Unknown envelopes remain typed +incompatibility results and are not silently treated as misses. Envelope integrity is checked +before the version byte is trusted. + +The policy contains positive soft TTL, positive hard TTL, negative TTL, jitter ratio, minimum hard +TTL and maximum value bytes. Construction rejects: + +- non-positive or over-30-day TTLs; +- soft TTL greater than hard TTL; +- jitter outside `0.0..0.5`; +- minimum hard TTL greater than either configured hard TTL. +- configured hard TTL plus maximum positive jitter greater than 30 days. + +Jitter is deterministic from the HMAC-derived physical key and the compiled policy revision. It +uses a symmetric bounded factor. The actual positive soft/hard TTLs use the same factor so ordering +is preserved. Physical Redis TTL equals the encoded hard expiry duration in the same `SET`. +Negative TTL is jittered independently and also respects the hard minimum. + +## Evidence + +Tests must prove: + +- fresh/negative hits do not call the source; +- concurrent same-key misses call the loader once; +- in-flight-key, waiter, bulkhead and deadline bounds; +- completion/failure cleanup and exception/interruption behavior; +- only authoritative absence is negative-cached; +- stale is served only after a classified transient failure; +- fresh/stale/expired boundaries with an injected `Clock`; +- deterministic bounded jitter and hard minimum; +- version 1/future/corrupt envelope behavior; +- Redis physical TTL matches the encoded hard expiry. +- observed replace reads only the trailing digest and never overwrites a concurrent writer; +- the exact 16MiB opt-in payload is accepted while 16MiB+1 is rejected before dispatch; +- mutation interruption restores the thread flag and maps to indeterminate certainty. + +Focused checks run before the repository-wide architecture, dependency, env and public-path gates. diff --git a/docs/superpowers/specs/2026-07-28-redis-distributed-rate-limit-design.md b/docs/superpowers/specs/2026-07-28-redis-distributed-rate-limit-design.md new file mode 100644 index 0000000..4d2c42d --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-redis-distributed-rate-limit-design.md @@ -0,0 +1,144 @@ +# Redis Distributed Rate-Limit Increment Design + +**Status:** implemented as standalone R1 + +**Parent:** `2026-07-26-redis-production-capability-design.md` §§19–21 + +## Goal and readiness + +Provide three selectable, bounded distributed rate-limit algorithms: + +- fixed window; +- sliding-window counter; +- token bucket. + +This increment is a standalone Redis R1 provider. It does not claim R2 topology/security/failover +qualification and does not implement sliding log, GCRA, leaky bucket, evaluation dedup, hierarchical +all-or-nothing policies or local emergency fallback. + +## Ownership + +- `shared-contract` owns the edge-enforcement semantic port and provider-neutral request, policy, + decision and failure outcomes. Business quotas remain application use-case policy and do not use + this port. +- `adapter:outbound:cache-redis` owns Redis keys, atomic Lua programs, structured reply parsing, + failure certainty and the provider implementation. +- `app-bootstrap` owns the explicit provider/policy selection. +- The existing inbound-web local limiter remains a compatibility path until a separate inbound + migration. Its types do not cross into the Redis provider. + +The rate-limit runtime does not reuse `app.cache.redis`, the cache connection or cache fail-open +decorators. Coordination has different failure and deployment semantics. + +## Shared semantic contract + +`EdgeRateLimitPort.evaluate(RateLimitRequest)` accepts: + +- bounded `policyId`; +- already pseudonymized/bounded `subjectDigest`; +- positive request cost; +- optional evaluation ID (rejected in this non-deduplicating revision); +- finite caller deadline. + +`RateLimitPolicy` freezes policy ID/revision, one algorithm-specific parameter subtype, maximum +cost, cleanup grace, maximum clock regression and `FAIL_CLOSED`. Construction rejects mismatched +algorithm/parameters, arithmetic outside Lua's exact integer range and unsupported failure/dedup +claims. + +The outcome is one of: + +- `Evaluated(decision)`; +- `Unavailable(policyId, retryAfter, category)` for known pre-send/no-mutation failures and unsafe + server clock; +- `Indeterminate(policyId, retryAfter)` for post-dispatch uncertain mutation; +- `Incompatible(policyId, category)` for state/program/reply mismatch. + +`RateLimitDecision` includes allow/deny, limit, remaining, retry-after, reset-at, policy ID/revision, +`GLOBAL_REDIS` source and certainty. Fixed window and token bucket are `CERTAIN`; +sliding-window counter is `APPROXIMATE_ALGORITHM`. + +## Atomic programs + +Each v1 program uses one versioned hash key and calls Redis `TIME` exactly once. + +```text +rate-fixed-window-v1.lua +rate-sliding-counter-v1.lua +rate-token-bucket-v1.lua +``` + +Every program returns exactly seven bounded scalar fields: + +```text +status, serverNowMillis, effectiveNowMillis, +limit, remaining, retryAfterMillis, resetAtMillis +``` + +Statuses are `ALLOWED`, `DENIED`, `CLOCK_UNSAFE`, `STATE_INCOMPATIBLE`, `INVALID`. +Unknown arity/status/numeric syntax/range is a compatibility failure, never allow/fail-open. + +Common rules: + +- Redis server time drives enforcement; +- small backward movement clamps to stored `lastObservedMillis`; +- regression beyond policy threshold returns `CLOCK_UNSAFE` without consuming state; +- policy/schema/algorithm mismatch returns `STATE_INCOMPATIBLE`; +- denied requests do not consume quota; +- state receives a finite TTL; +- all arithmetic stays within `2^53-1`; +- raw principal/IP/API-key/route never appears in the physical key. + +The existing scalar Lua executor stays intact. A structured program path adds bounded MULTI reply +support and uses `EVALSHA`, falling back to the exact compiled source only on `NOSCRIPT`. + +## Algorithm rules + +Fixed window stores window ID and consumed count. Allow increments only when +`consumed + cost <= limit`; retry/reset points to the current window end. + +Sliding counter stores previous/current window IDs and counts, using scale `1_000_000` and +conservative ceiling weight. It reports approximate certainty and a bounded conservative retry. + +Token bucket stores scaled tokens, last refill time and the sub-token division remainder. Refill is +therefore independent of evaluation frequency, uses quotient/remainder arithmetic without an +unsafe `numerator + denominator - 1` intermediate, and saturates at capacity. Denial does not +subtract tokens; retry and full-reset use integer ceiling. + +## Physical key + +The existing canonical builder is reused with: + +```text +capability=rate +region= +kind=state +digest(policyId, policyRevision, algorithm, subjectDigest) +``` + +Policy revision appears in both digest input and stored state. A policy revision therefore rolls to +a new key while old state expires naturally. + +## Runtime and composition + +`app.rate-limit` is disabled by default. Enabling requires: + +- `provider=redis`; +- one default policy and an exact policy definition; +- a dedicated Redis coordination endpoint and HMAC secret; +- finite command/admission bounds. + +Only `role=coordination` and `failure-policy=fail-closed` are accepted in v1. Disabled mode creates +no connection, thread or semantic port. Cache Redis settings/beans are never an implicit fallback. + +## Evidence + +Unit tests cover contract bounds, policy arithmetic, key privacy/revision, structured reply +validation, `NOSCRIPT`, boundary vectors, denial-no-consume, clock regression, pre/post-dispatch +failure certainty and disabled composition. The explicit Redis 7.4 service lane executes all three +programs, exact-boundary admission after a denied non-consuming request, excessive clock-regression +state immutability, `TYPE` response normalization, token refill-remainder carry, malformed hash-state +classification, cache `NX`, and observation-token compare-and-replace. Redis 7.4 is the minimum +version declared by the program manifests until a lower-version service lane exists. The caller +deadline is an admission precheck against the fixed command timeout; R1 does not claim per-command +dynamic timeout or hard cancellation after dispatch. Missing TLS/ACL, Sentinel/Cluster, failover and +persistence/eviction evidence keeps the provider at R1. diff --git a/infra/redis-lab/README.md b/infra/redis-lab/README.md new file mode 100644 index 0000000..e1d78c7 --- /dev/null +++ b/infra/redis-lab/README.md @@ -0,0 +1,134 @@ +# Disposable Redis qualification lab + +This directory owns the lifecycle contract for the isolated three-node k3s lab. It does not contain +Redis workloads, credentials, certificates, or qualification evidence. + +## Fixed topology + +| Instance | CPU | Memory | Disk | Role | +| --- | ---: | ---: | ---: | --- | +| `ca-redis-lab-server` | 2 | 3G | 12G | k3s server | +| `ca-redis-lab-agent-1` | 2 | 2560M | 12G | k3s agent | +| `ca-redis-lab-agent-2` | 2 | 2560M | 12G | k3s agent | + +The lab uses pod CIDR `10.52.0.0/16`, service CIDR `10.53.0.0/16`, and context +`ca-redis-lab`. `versions.env` pins the k3s version and Multipass image. Traefik and ServiceLB are +disabled. + +## Safety model + +All state, rendered cloud-init, kubeconfigs, tokens, and raw observations are mode-restricted +beneath the ignored `src/build/redis-lab` directory. Every canonical ancestor from the repository +root through `src/build/redis-lab`, plus runtime children, is validated before observation or +mutation; a symlink or real-path escape fails closed. The lifecycle never exports `KUBECONFIG`, +merges a kubeconfig, or writes the user's default kubeconfig. + +Host observation and lab access deliberately use different explicit targets: + +- host read-only queries copy the default kubeconfig into + `src/build/redis-lab/observations/host-kubeconfig` and use its unchanged original context; +- lab read-only queries use `src/build/redis-lab/kubeconfig` and exact context `ca-redis-lab`. + +This split preserves the host context identity while ensuring no kubectl call relies on an implicit +target. Host kubectl mutations are not part of the lifecycle. The observation-only host copy is +removed after fingerprint and CIDR observation on success and every handled failure path. + +Each exact name gets a private mode-`0600` rendered cloud-init file beneath +`src/build/redis-lab/cloud-init`. It writes only the non-secret ownership marker +`RUN_ID|VM_NAME` to `/var/lib/ca-redis-lab/ownership` as `root:root` mode `0600`; launch uses only +that rendered file. + +Each name is then atomically reserved as `PENDING` in `run.state` before its bounded launch. The +state starts with an exact per-run identity, and each `PENDING`/`CREATED`/`RECONCILE` entry carries +that same identity. A successful launch becomes `CREATED` only after a bounded +`multipass exec -- sudo cat /var/lib/ca-redis-lab/ownership` returns the exact marker. +Timeout, launch error, missing/foreign marker, signal, promotion failure, or uncertain cleanup +enters `RECONCILE`. + +Cleanup transitions a recorded entry to `RECONCILE`, bounded-polls the exact instance and marker, +and issues `multipass delete --purge ` only after the marker matches. It atomically +removes only an entry whose delete succeeded. A late-created matching instance is deleted; an +absent instance, unreadable marker, mismatched/foreign marker, or failed delete is retained as a +tombstone and fails closed without an unproven delete. Existing instance-bearing state blocks a +new `preflight`, `up`, or `run`; a rejected new run does not clean the prior run, and `down` is the +retry/reconciliation entry point. Existing +allowlisted names without owned state cause `up` to stop before reservation/launch and are never +adopted or deleted. Wildcards, `--all`, global purge, and discovered-instance deletion are +forbidden. + +The lifecycle lock is nonblocking and exclusive. External children close its descriptor by +default, including detached infrastructure descendants and `run --` commands; only lock +acquisition retains descriptor 9. + +Multipass list/launch/info/exec/transfer/delete, installation/join, and kubectl calls have fixed +time bounds. `up` succeeds only after the exact server and two agents all report `Ready=True` +within the bounded poll budget; incomplete or not-ready inventory enters marker-proven run-owned +cleanup. + +The k3s runtime is amd64-only and fail-closed in this slice. `versions.env` pins the immutable +release URL and exact SHA-256 for `v1.33.3+k3s1`. The lifecycle performs a bounded host download, +verifies the digest, transfers the binary to each exact VM, verifies the transferred digest and +reported binary version inside each VM, and only then installs/starts it. It does not execute a +network installer or a `curl | sh` pipeline. + +The generated lab kubeconfig is accepted only in the pinned single-cluster/single-context/ +single-user block grammar. A tracked AWK state machine has one explicit transition for every +allowlisted line and publishes no output until the complete document reaches its exact final +state. It rejects missing, duplicate, reordered, unknown, whitespace-altered, quoted, tagged, or +explicit keys; anchors, aliases, merge keys, tabs, CRLF, document markers, trailing content, and +all flow collections except exact `preferences: {}`. Only the exact cluster/context/user identity, +`current-context`, and loopback API server are rewritten. CA data, client certificate/key data, +and an optional canonical namespace are byte-preserved. + +Rendering uses a same-directory `kubeconfig.next`, applies mode `0600`, and replaces the +destination only after render and permission success. The renderer must be a readable regular +non-symlink file at its canonical tracked path, and both destination paths are protected by the +runtime symlink contract. Renderer, permission, or move failure removes both candidate and +destination, performs no lab `kubectl`, and enters exact marker-proven current-run cleanup. + +Assigned Service ClusterIPs cannot prove the host service CIDR. When a host kubeconfig exists, +callers must supply one or more canonical, comma- or space-separated IPv4 CIDRs through +`REDIS_LAB_HOST_SERVICE_CIDRS`. Missing, malformed, or overlapping input fails before launch: + +```bash +REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \ + infra/redis-lab/bin/redis-lab preflight +``` + +## Commands + +The real lifecycle is for a trusted local or dedicated runner only: + +```bash +REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab preflight +REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab up +infra/redis-lab/bin/redis-lab down +REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab run -- command +REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \ + infra/redis-lab/bin/redis-lab run --retain-on-failure -- command +``` + +`run` establishes its cleanup obligation before entering the inner `up`, keeps it through the +post-up/pre-command handoff and user command, then tears down after command success or failure and +compares the canonical pre/post host fingerprints after cleanup. A successful direct `up` retains +the lab by design. Local `--retain-on-failure` intentionally leaves the recorded lab for diagnosis +and skips an isolation-success claim; `CI=true` rejects that option before launch. + +The blocking contract is VM-free: + +```bash +cd src +./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain +``` + +It injects fake infrastructure commands. Hosted CI must run only this contract, never the real lab. +The contract executes a copied lifecycle in +`src/build/redis-lab-contract/repository`, seals `PATH` to explicit fakes/safe wrappers, and compares +a byte-level snapshot proving it did not modify the real repository's `src/build/redis-lab`. It +also exercises direct/run signal cleanup, rendered-child symlink rejection, successful and +late-create marker proof, absent/foreign-marker tombstones, second-run state preservation, +CREATED cleanup uncertainty, rejected-run preservation of prior `CREATED` and `RECONCILE` state, +the post-up/pre-command signal handoff, the canonical kubeconfig mutation matrix, missing/symlinked +renderer rejection, fail-closed `.next`/permission/move publication, and infrastructure/user +background-child lock non-inheritance. This is deterministic fake-runtime evidence only; it is not +live Multipass, k3s, kubectl, network, or host-isolation qualification. diff --git a/infra/redis-lab/bin/redis-lab b/infra/redis-lab/bin/redis-lab new file mode 100755 index 0000000..76d1a89 --- /dev/null +++ b/infra/redis-lab/bin/redis-lab @@ -0,0 +1,1540 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +SCRIPT_PATH="${BASH_SOURCE[0]}" +if [[ "${SCRIPT_PATH}" != /* ]]; then + SCRIPT_PATH="${PWD}/${SCRIPT_PATH}" +fi +SCRIPT_DIR="$(cd -P -- "${SCRIPT_PATH%/*}" && pwd -P)" +REPOSITORY_ROOT="$(cd -P -- "${SCRIPT_DIR}/../../.." && pwd -P)" +SRC_ROOT="${REPOSITORY_ROOT}/src" +BUILD_ROOT="${SRC_ROOT}/build" +LAB_ROOT="${BUILD_ROOT}/redis-lab" +OBSERVATION_ROOT="${LAB_ROOT}/observations" +CLOUD_INIT_ROOT="${LAB_ROOT}/cloud-init" +HOST_KUBECONFIG="${OBSERVATION_ROOT}/host-kubeconfig" +LAB_KUBECONFIG="${LAB_ROOT}/kubeconfig" +STATE_FILE="${LAB_ROOT}/run.state" +LOCK_FILE="${LAB_ROOT}/lifecycle.lock" +TOKEN_FILE="${LAB_ROOT}/k3s-token" +K3S_BINARY="${LAB_ROOT}/k3s-amd64" +RUN_HANDOFF_MARKER="${LAB_ROOT}/run-handoff.started" +VERSIONS_FILE="${REPOSITORY_ROOT}/infra/redis-lab/versions.env" +CLOUD_INIT_FILE="${REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" +KUBECONFIG_RENDERER="${REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" +DEFAULT_KUBECONFIG_ROOT="${REDIS_LAB_HOME_DIR:-${HOME}}" +DEFAULT_KUBECONFIG="${DEFAULT_KUBECONFIG_ROOT}/.kube/config" +CONTEXT_NAME='ca-redis-lab' +POD_CIDR='10.52.0.0/16' +SERVICE_CIDR='10.53.0.0/16' +SERVER_NAME='ca-redis-lab-server' +AGENT_ONE_NAME='ca-redis-lab-agent-1' +AGENT_TWO_NAME='ca-redis-lab-agent-2' +OWNERSHIP_MARKER_PATH='/var/lib/ca-redis-lab/ownership' +MULTIPASS_LIST_TIMEOUT_SECONDS=30 +MULTIPASS_INFO_TIMEOUT_SECONDS=30 +MULTIPASS_LAUNCH_TIMEOUT_SECONDS=300 +MULTIPASS_EXEC_TIMEOUT_SECONDS=300 +KUBECTL_TIMEOUT_SECONDS=20 +RECONCILE_ATTEMPTS=3 +RECONCILE_INTERVAL_SECONDS=1 +READY_ATTEMPTS=9 +READY_INTERVAL_SECONDS=2 +RUN_ID='' +RUN_OWNERSHIP_ACTIVE=0 +TRACKED_INPUTS_VALIDATED=0 +K3S_VERSION='' +MULTIPASS_IMAGE='' +K3S_AMD64_URL='' +K3S_AMD64_SHA256='' + +fail() { + printf 'redis-lab: %s\n' "$1" >&2 + return 1 +} + +usage() { + printf '%s\n' \ + 'usage: redis-lab preflight|up|down|postflight|run [--retain-on-failure] -- command [args...]' \ + >&2 + return 64 +} + +validate_tracked_input_paths() { + local tracked_parent + local canonical_parent + + for tracked_parent in \ + "${REPOSITORY_ROOT}/infra" \ + "${REPOSITORY_ROOT}/infra/redis-lab" \ + "${REPOSITORY_ROOT}/infra/redis-lab/cloud-init" \ + "${REPOSITORY_ROOT}/infra/redis-lab/lib"; do + if [[ -L "${tracked_parent}" || ! -d "${tracked_parent}" ]]; then + return 1 + fi + if ! canonical_parent="$(cd -P -- "${tracked_parent}" && pwd -P)" || + [[ "${canonical_parent}" != "${tracked_parent}" ]]; then + return 1 + fi + done + if [[ "${VERSIONS_FILE}" != "${REPOSITORY_ROOT}/infra/redis-lab/versions.env" || + -L "${VERSIONS_FILE}" || ! -f "${VERSIONS_FILE}" || ! -r "${VERSIONS_FILE}" || + "${CLOUD_INIT_FILE}" != "${REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" || + -L "${CLOUD_INIT_FILE}" || ! -f "${CLOUD_INIT_FILE}" || ! -r "${CLOUD_INIT_FILE}" || + "${KUBECONFIG_RENDERER}" != "${REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" || + -L "${KUBECONFIG_RENDERER}" || ! -f "${KUBECONFIG_RENDERER}" || + ! -r "${KUBECONFIG_RENDERER}" ]]; then + return 1 + fi +} + +load_tracked_versions() { + local line + local key + local value + local line_count=0 + local parsed_k3s_version='' + local parsed_multipass_image='' + local parsed_k3s_amd64_url='' + local parsed_k3s_amd64_sha256='' + local -A seen_keys=() + + while IFS= read -r line || [[ -n "${line}" ]]; do + ((line_count += 1)) + if ((line_count > 4)) || + [[ ! "${line}" =~ ^([A-Z0-9_]+)=([^[:space:]=]+)$ ]]; then + fail 'tracked lab input unavailable' + return 1 + fi + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + case "${key}" in + K3S_VERSION) + [[ ! -v 'seen_keys[K3S_VERSION]' ]] || { + fail 'tracked lab input unavailable' + return 1 + } + parsed_k3s_version="${value}" + ;; + MULTIPASS_IMAGE) + [[ ! -v 'seen_keys[MULTIPASS_IMAGE]' ]] || { + fail 'tracked lab input unavailable' + return 1 + } + parsed_multipass_image="${value}" + ;; + K3S_AMD64_URL) + [[ ! -v 'seen_keys[K3S_AMD64_URL]' ]] || { + fail 'tracked lab input unavailable' + return 1 + } + parsed_k3s_amd64_url="${value}" + ;; + K3S_AMD64_SHA256) + [[ ! -v 'seen_keys[K3S_AMD64_SHA256]' ]] || { + fail 'tracked lab input unavailable' + return 1 + } + parsed_k3s_amd64_sha256="${value}" + ;; + *) + fail 'tracked lab input unavailable' + return 1 + ;; + esac + seen_keys["${key}"]=1 + done <"${VERSIONS_FILE}" + if ((line_count != 4 || ${#seen_keys[@]} != 4)); then + fail 'tracked lab input unavailable' + return 1 + fi + if [[ ! "${parsed_k3s_version}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+$ ]]; then + fail 'invalid pinned version' + return 1 + fi + if [[ "${parsed_multipass_image}" != '24.04' ]]; then + fail 'invalid pinned image' + return 1 + fi + if [[ "${parsed_k3s_amd64_url}" != 'https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' || + "${parsed_k3s_amd64_sha256}" != 'f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' ]]; then + fail 'invalid pinned artifact' + return 1 + fi + K3S_VERSION="${parsed_k3s_version}" + MULTIPASS_IMAGE="${parsed_multipass_image}" + K3S_AMD64_URL="${parsed_k3s_amd64_url}" + K3S_AMD64_SHA256="${parsed_k3s_amd64_sha256}" +} + +validate_and_load_tracked_inputs() { + if ! validate_tracked_input_paths; then + fail 'tracked lab input unavailable' + return 1 + fi + if ! load_tracked_versions; then + return 1 + fi + TRACKED_INPUTS_VALIDATED=1 +} + +run_child() { + "$@" 9>&- +} + +bounded() { + local seconds="$1" + shift + timeout --signal=TERM --kill-after=5s "${seconds}s" "$@" 9>&- +} + +bounded_keep_lock() { + local seconds="$1" + shift + timeout --signal=TERM --kill-after=5s "${seconds}s" "$@" +} + +is_allowlisted_name() { + case "$1" in + "${SERVER_NAME}" | "${AGENT_ONE_NAME}" | "${AGENT_TWO_NAME}") + return 0 + ;; + *) + return 1 + ;; + esac +} + +validate_static_contract() { + local child_path + if [[ "${LAB_ROOT}" != "${REPOSITORY_ROOT}/src/build/redis-lab" ]]; then + fail 'invalid transient path' + return 1 + fi + if [[ -L "${LAB_ROOT}" || -L "${OBSERVATION_ROOT}" ]]; then + fail 'invalid transient path' + return 1 + fi + if ! validate_tracked_input_paths; then + fail 'tracked lab input unavailable' + return 1 + fi + if [[ ! "${K3S_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+$ ]]; then + fail 'invalid pinned version' + return 1 + fi + if [[ "${MULTIPASS_IMAGE}" != '24.04' ]]; then + fail 'invalid pinned image' + return 1 + fi + if [[ "${K3S_AMD64_URL}" != 'https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' || + "${K3S_AMD64_SHA256}" != 'f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' ]]; then + fail 'invalid pinned artifact' + return 1 + fi + if [[ -L "${SRC_ROOT}" || ! -d "${SRC_ROOT}" || + "$(cd -- "${SRC_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src" ]]; then + fail 'invalid transient path' + return 1 + fi + if [[ -e "${BUILD_ROOT}" || -L "${BUILD_ROOT}" ]]; then + if [[ -L "${BUILD_ROOT}" || ! -d "${BUILD_ROOT}" || + "$(cd -- "${BUILD_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src/build" ]]; then + fail 'invalid transient path' + return 1 + fi + fi + if [[ -e "${LAB_ROOT}" || -L "${LAB_ROOT}" ]]; then + if [[ -L "${LAB_ROOT}" || ! -d "${LAB_ROOT}" || + "$(cd -- "${LAB_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src/build/redis-lab" ]]; then + fail 'invalid transient path' + return 1 + fi + fi + if [[ -e "${OBSERVATION_ROOT}" || -L "${OBSERVATION_ROOT}" ]]; then + if [[ -L "${OBSERVATION_ROOT}" || ! -d "${OBSERVATION_ROOT}" || + "$(cd -- "${OBSERVATION_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src/build/redis-lab/observations" ]]; then + fail 'invalid transient path' + return 1 + fi + fi + if [[ -e "${CLOUD_INIT_ROOT}" || -L "${CLOUD_INIT_ROOT}" ]]; then + if [[ -L "${CLOUD_INIT_ROOT}" || ! -d "${CLOUD_INIT_ROOT}" || + "$(cd -- "${CLOUD_INIT_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src/build/redis-lab/cloud-init" ]]; then + fail 'invalid transient path' + return 1 + fi + fi + for child_path in \ + "${STATE_FILE}" \ + "${STATE_FILE}.next" \ + "${LOCK_FILE}" \ + "${TOKEN_FILE}" \ + "${K3S_BINARY}" \ + "${RUN_HANDOFF_MARKER}" \ + "${LAB_KUBECONFIG}" \ + "${LAB_KUBECONFIG}.next" \ + "${LAB_ROOT}/kubeconfig.rendered" \ + "${LAB_ROOT}/fingerprint.before" \ + "${LAB_ROOT}/fingerprint.after" \ + "${LAB_ROOT}/existing-inventory.csv"; do + if [[ -L "${child_path}" ]]; then + fail 'invalid transient path' + return 1 + fi + done + for child_path in "${LAB_ROOT}"/* "${OBSERVATION_ROOT}"/* "${CLOUD_INIT_ROOT}"/*; do + if [[ -L "${child_path}" ]]; then + fail 'invalid transient path' + return 1 + fi + done + for child_path in \ + "${OBSERVATION_ROOT}/host-kubeconfig" \ + "${OBSERVATION_ROOT}/host-service-cidrs" \ + "${OBSERVATION_ROOT}/cidr-inputs.raw" \ + "${OBSERVATION_ROOT}/fingerprint-before.raw" \ + "${OBSERVATION_ROOT}/fingerprint-after.raw" \ + "${OBSERVATION_ROOT}/multipass-before.csv" \ + "${OBSERVATION_ROOT}/multipass-after.csv" \ + "${OBSERVATION_ROOT}/lab-node-readiness.raw" \ + "${OBSERVATION_ROOT}/lab-node-readiness.sorted" \ + "${OBSERVATION_ROOT}/lab-node-readiness.expected"; do + if [[ -L "${child_path}" ]]; then + fail 'invalid transient path' + return 1 + fi + done +} + +acquire_lifecycle_lock() { + if ! validate_static_contract; then + return 1 + fi + if ! run_child mkdir -p -- "${BUILD_ROOT}" "${LAB_ROOT}"; then + fail 'transient path unavailable' + return 1 + fi + if ! validate_static_contract; then + return 1 + fi + if ! run_child chmod 0700 -- "${LAB_ROOT}"; then + fail 'transient permissions unavailable' + return 1 + fi + if ! exec 9>>"${LOCK_FILE}"; then + fail 'lifecycle lock unavailable' + return 1 + fi + if ! bounded_keep_lock 5 flock -n 9; then + fail 'lifecycle already active' + return 1 + fi +} + +prepare_runtime() { + if ! validate_static_contract; then + return 1 + fi + if ! run_child mkdir -p -- "${BUILD_ROOT}"; then + fail 'transient path unavailable' + return 1 + fi + if [[ -L "${BUILD_ROOT}" || + "$(cd -- "${BUILD_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src/build" ]]; then + fail 'invalid transient path' + return 1 + fi + if ! run_child mkdir -p -- "${LAB_ROOT}" "${OBSERVATION_ROOT}"; then + fail 'transient path unavailable' + return 1 + fi + if ! validate_static_contract; then + return 1 + fi + if ! run_child chmod 0700 -- "${LAB_ROOT}" "${OBSERVATION_ROOT}"; then + fail 'transient permissions unavailable' + return 1 + fi +} + +start_new_run() { + if [[ -v run_state_owned ]]; then + run_state_owned=1 + fi + if ! run_child rm -rf -- "${OBSERVATION_ROOT}" "${CLOUD_INIT_ROOT}"; then + fail 'transient path unavailable' + return 1 + fi + if ! run_child rm -f -- \ + "${LAB_KUBECONFIG}" \ + "${TOKEN_FILE}" \ + "${K3S_BINARY}" \ + "${RUN_HANDOFF_MARKER}" \ + "${STATE_FILE}.next"; then + fail 'transient path unavailable' + return 1 + fi + if ! run_child mkdir -p -- "${OBSERVATION_ROOT}" || + ! run_child chmod 0700 -- "${OBSERVATION_ROOT}"; then + fail 'transient path unavailable' + return 1 + fi + RUN_ID="run-${BASHPID}-${RANDOM}-${RANDOM}" + if [[ ! "${RUN_ID}" =~ ^run-[0-9]+-[0-9]+-[0-9]+$ ]]; then + fail 'run identity unavailable' + return 1 + fi + if ! printf 'RUN|%s\n' "${RUN_ID}" >"${STATE_FILE}.next"; then + fail 'run state unavailable' + return 1 + fi + if ! install_state_next; then + return 1 + fi +} + +cleanup_host_kubeconfig() { + if [[ -L "${BUILD_ROOT}" || -L "${LAB_ROOT}" || -L "${OBSERVATION_ROOT}" || + ! -d "${OBSERVATION_ROOT}" ]]; then + return 0 + fi + if [[ "$(cd -- "${OBSERVATION_ROOT}" && pwd -P)" != "${REPOSITORY_ROOT}/src/build/redis-lab/observations" ]]; then + return 0 + fi + run_child rm -f -- "${HOST_KUBECONFIG}" +} + +install_state_next() { + local state_next="${STATE_FILE}.next" + + if ! run_child chmod 0600 -- "${state_next}"; then + run_child rm -f -- "${state_next}" + fail 'run state unavailable' + return 1 + fi + if ! run_child mv -- "${state_next}" "${STATE_FILE}"; then + run_child rm -f -- "${state_next}" + fail 'run state unavailable' + return 1 + fi +} + +reserve_attempt_name() { + local name="$1" + local state_next="${STATE_FILE}.next" + + if ! is_allowlisted_name "${name}"; then + fail 'invalid lab instance name' + return 1 + fi + if ! run_child cp -- "${STATE_FILE}" "${state_next}"; then + fail 'run state unavailable' + return 1 + fi + if [[ -z "${RUN_ID}" ]]; then + fail 'run identity unavailable' + return 1 + fi + if ! printf 'PENDING|%s|%s\n' "${RUN_ID}" "${name}" >>"${state_next}"; then + run_child rm -f -- "${state_next}" + fail 'run state unavailable' + return 1 + fi + install_state_next +} + +promote_attempt_name() { + local name="$1" + local state_next="${STATE_FILE}.next" + + if ! run_child awk -F'|' -v owner="${RUN_ID}" -v target="${name}" ' + $1 == "PENDING" && $2 == owner && $3 == target { + print "CREATED|" owner "|" target + promoted += 1 + next + } + { + print + } + END { + if (promoted != 1) { + exit 1 + } + } + ' "${STATE_FILE}" >"${state_next}"; then + run_child rm -f -- "${state_next}" + fail 'run state unavailable' + return 1 + fi + install_state_next +} + +mark_attempt_reconcile() { + local name="$1" + local state_next="${STATE_FILE}.next" + + if ! run_child awk -F'|' -v owner="${RUN_ID}" -v target="${name}" ' + ($1 == "PENDING" || $1 == "CREATED") && $2 == owner && $3 == target { + print "RECONCILE|" owner "|" target + changed += 1 + next + } + $1 == "RECONCILE" && $2 == owner && $3 == target { + print + changed += 1 + next + } + { + print + } + END { + if (changed != 1) { + exit 1 + } + } + ' "${STATE_FILE}" >"${state_next}"; then + run_child rm -f -- "${state_next}" + fail 'run state unavailable' + return 1 + fi + install_state_next +} + +remove_attempt_name() { + local name="$1" + local state_next="${STATE_FILE}.next" + + if ! run_child awk -F'|' -v owner="${RUN_ID}" -v target="${name}" ' + $1 != "RUN" && $2 == owner && $3 == target { + removed += 1 + next + } + { + print + } + END { + if (removed != 1) { + exit 1 + } + } + ' "${STATE_FILE}" >"${state_next}"; then + run_child rm -f -- "${state_next}" + fail 'run state unavailable' + return 1 + fi + install_state_next +} + +validate_recorded_state() { + local status owner name extra + local line_number=0 + local count=0 + local seen_server=0 + local seen_agent_one=0 + local seen_agent_two=0 + + [[ -f "${STATE_FILE}" ]] || return 0 + while IFS='|' read -r status owner name extra; do + [[ -n "${status}" ]] || continue + ((line_number += 1)) + if ((line_number == 1)); then + if [[ "${status}" != RUN || -z "${owner}" || -n "${name}" || -n "${extra}" || + ! "${owner}" =~ ^run-[0-9]+-[0-9]+-[0-9]+$ ]]; then + fail 'invalid run state' + return 1 + fi + if [[ -n "${RUN_ID}" && "${RUN_ID}" != "${owner}" ]]; then + fail 'invalid run state' + return 1 + fi + RUN_ID="${owner}" + continue + fi + if [[ -n "${extra}" || ! "${status}" =~ ^(PENDING|CREATED|RECONCILE)$ || + "${owner}" != "${RUN_ID}" ]]; then + fail 'invalid run state' + return 1 + fi + if ! is_allowlisted_name "${name}"; then + fail 'invalid run state' + return 1 + fi + case "${name}" in + "${SERVER_NAME}") + ((seen_server += 1)) + if [[ "${seen_server}" != 1 ]]; then + fail 'invalid run state' + return 1 + fi + ;; + "${AGENT_ONE_NAME}") + ((seen_agent_one += 1)) + if [[ "${seen_agent_one}" != 1 ]]; then + fail 'invalid run state' + return 1 + fi + ;; + "${AGENT_TWO_NAME}") + ((seen_agent_two += 1)) + if [[ "${seen_agent_two}" != 1 ]]; then + fail 'invalid run state' + return 1 + fi + ;; + esac + ((count += 1)) + if ((count > 3)); then + fail 'invalid run state' + return 1 + fi + done <"${STATE_FILE}" + if ((line_number == 0)); then + fail 'invalid run state' + return 1 + fi +} + +recorded_state_has_instances() { + local status owner name extra + + [[ -f "${STATE_FILE}" ]] || return 1 + while IFS='|' read -r status owner name extra; do + if [[ -n "${status}" && "${status}" != RUN ]]; then + return 0 + fi + done <"${STATE_FILE}" + return 1 +} + +wait_for_owned_instance() { + local owner="$1" + local name="$2" + local marker='' + local attempt + + for ((attempt = 1; attempt <= RECONCILE_ATTEMPTS; attempt += 1)); do + if bounded "${MULTIPASS_INFO_TIMEOUT_SECONDS}" \ + multipass info --format csv "${name}" >/dev/null 2>&1; then + marker='' + if marker="$(bounded "${MULTIPASS_INFO_TIMEOUT_SECONDS}" \ + multipass exec "${name}" -- sudo cat "${OWNERSHIP_MARKER_PATH}")" && + [[ "${marker}" == "${owner}|${name}" ]]; then + return 0 + fi + fi + if ((attempt < RECONCILE_ATTEMPTS)); then + bounded 5 sleep "${RECONCILE_INTERVAL_SECONDS}" || true + fi + done + return 1 +} + +delete_if_owned() { + local owner="$1" + local name="$2" + + if ! wait_for_owned_instance "${owner}" "${name}"; then + fail 'lab ownership unresolved' + return 1 + fi + if ! bounded "${MULTIPASS_INFO_TIMEOUT_SECONDS}" \ + multipass delete --purge "${name}"; then + fail 'lab cleanup failed' + return 1 + fi + return 0 +} + +cleanup_recorded() { + local status owner name extra + local index + local cleanup_status=0 + local -a statuses=() + local -a owners=() + local -a names=() + + validate_recorded_state || return 1 + [[ -f "${STATE_FILE}" ]] || return 0 + while IFS='|' read -r status owner name extra; do + [[ -n "${status}" && "${status}" != RUN ]] || continue + statuses+=("${status}") + owners+=("${owner}") + names+=("${name}") + done <"${STATE_FILE}" + + for index in "${!names[@]}"; do + status="${statuses[${index}]}" + owner="${owners[${index}]}" + name="${names[${index}]}" + if [[ "${status}" != RECONCILE ]]; then + if ! mark_attempt_reconcile "${name}"; then + cleanup_status=1 + continue + fi + fi + if delete_if_owned "${owner}" "${name}"; then + if ! remove_attempt_name "${name}"; then + cleanup_status=1 + fi + else + cleanup_status=1 + fi + done + + if [[ -f "${STATE_FILE}" ]] && ! recorded_state_has_instances; then + if ! run_child rm -f -- \ + "${STATE_FILE}" \ + "${TOKEN_FILE}" \ + "${LAB_KUBECONFIG}" \ + "${K3S_BINARY}" || + ! run_child rm -rf -- "${CLOUD_INIT_ROOT}"; then + cleanup_status=1 + fi + fi + return "${cleanup_status}" +} + +cleanup_failed_attempt() { + local name="$1" + + if ! mark_attempt_reconcile "${name}"; then + return 1 + fi + cleanup_recorded +} + +capture_multipass_inventory() { + local destination="$1" + if ! bounded "${MULTIPASS_LIST_TIMEOUT_SECONDS}" \ + multipass list --format csv >"${destination}"; then + fail 'multipass inventory unavailable' + return 1 + fi + run_child chmod 0600 -- "${destination}" +} + +reject_existing_names() { + local inventory_file="${LAB_ROOT}/existing-inventory.csv" + local name + local ignored + + capture_multipass_inventory "${inventory_file}" || return 1 + while IFS=, read -r name ignored; do + [[ "${name}" != 'Name' ]] || continue + if is_allowlisted_name "${name}"; then + fail 'lab instance name already exists' + return 1 + fi + done <"${inventory_file}" + run_child rm -f -- "${inventory_file}" +} + +host_context_from_copy() { + run_child awk ' + $1 == "current-context:" { + print $2 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "${HOST_KUBECONFIG}" +} + +validate_context_name() { + if [[ ! "$1" =~ ^[A-Za-z0-9._@:/-]+$ ]]; then + fail 'invalid host context' + return 1 + fi +} + +host_kubectl() { + local host_context="$1" + shift + bounded "${KUBECTL_TIMEOUT_SECONDS}" kubectl \ + --kubeconfig "${HOST_KUBECONFIG}" \ + --context "${host_context}" \ + "$@" +} + +lab_kubectl() { + bounded "${KUBECTL_TIMEOUT_SECONDS}" kubectl \ + --kubeconfig "${LAB_KUBECONFIG}" \ + --context "${CONTEXT_NAME}" \ + "$@" +} + +append_lab_inventory_projection() { + local inventory_file="$1" + local projection_file="$2" + local name + local ignored + local count=0 + + while IFS=, read -r name ignored; do + [[ "${name}" != 'Name' ]] || continue + if is_allowlisted_name "${name}"; then + ((count += 1)) + printf 'lab-instance|%s\n' "${name}" >>"${projection_file}" + if ! bounded "${MULTIPASS_INFO_TIMEOUT_SECONDS}" \ + multipass info --format csv "${name}" | + LC_ALL=C run_child sort | + run_child awk -v instance="${name}" '{print "lab-resource|" instance "|" $0}' \ + >>"${projection_file}"; then + fail 'lab resource observation unavailable' + return 1 + fi + fi + done <"${inventory_file}" + printf 'lab-resource-count|%s\n' "${count}" >>"${projection_file}" +} + +capture_host_fingerprint() { + local phase="$1" + local raw_file="${OBSERVATION_ROOT}/fingerprint-${phase}.raw" + local inventory_file="${OBSERVATION_ROOT}/multipass-${phase}.csv" + local destination="${LAB_ROOT}/fingerprint.${phase}" + local host_context='' + + : >"${raw_file}" + run_child chmod 0600 -- "${raw_file}" + + if [[ -f "${DEFAULT_KUBECONFIG}" ]]; then + if ! run_child cp -- "${DEFAULT_KUBECONFIG}" "${HOST_KUBECONFIG}"; then + fail 'host kubeconfig observation unavailable' + return 1 + fi + run_child chmod 0600 -- "${HOST_KUBECONFIG}" + if ! host_context="$(host_context_from_copy)"; then + fail 'host kubeconfig observation unavailable' + return 1 + fi + validate_context_name "${host_context}" || return 1 + if ! REDIS_LAB_CAPTURE_PHASE="${phase}" \ + bounded 30 sha256sum "${DEFAULT_KUBECONFIG}" | + run_child awk '{print "default-kubeconfig-sha256|" $1}' >>"${raw_file}"; then + fail 'host kubeconfig observation unavailable' + return 1 + fi + printf 'host-current-context|%s\n' "${host_context}" >>"${raw_file}" + if ! host_kubectl "${host_context}" config view --minify \ + -o 'jsonpath={.clusters[0].cluster.server}' | + run_child awk '{print "host-api|" $0}' >>"${raw_file}"; then + fail 'host kube API observation unavailable' + return 1 + fi + if ! host_kubectl "${host_context}" get nodes \ + -o 'jsonpath={range .items[*]}{.metadata.name}{"|"}{.spec.providerID}{"|"}{range .spec.podCIDRs[*]}{.}{","}{end}{"\n"}{end}' | + LC_ALL=C run_child sort | + run_child awk '{print "host-node|" $0}' >>"${raw_file}"; then + fail 'host node observation unavailable' + return 1 + fi + if ! host_kubectl "${host_context}" get deployments,statefulsets,daemonsets \ + --all-namespaces \ + -o 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.kind}{"|"}{.metadata.name}{"|"}{.spec.replicas}{"\n"}{end}' | + LC_ALL=C run_child sort | + run_child awk '{print "host-controller|" $0}' >>"${raw_file}"; then + fail 'host controller observation unavailable' + return 1 + fi + if ! host_kubectl "${host_context}" get services --all-namespaces \ + -o 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{.spec.clusterIP}{"|"}{range .spec.ports[*]}{.nodePort}{","}{end}{"\n"}{end}' | + LC_ALL=C run_child sort | + run_child awk '{print "host-service|" $0}' >>"${raw_file}"; then + fail 'host service observation unavailable' + return 1 + fi + if ! resolve_host_service_cidrs "${OBSERVATION_ROOT}/host-service-cidrs"; then + return 1 + fi + run_child awk '{print "host-service-cidr|" $0}' \ + "${OBSERVATION_ROOT}/host-service-cidrs" >>"${raw_file}" + else + printf '%s\n' \ + 'default-kubeconfig-sha256|ABSENT' \ + 'host-current-context|ABSENT' \ + 'host-api|ABSENT' \ + 'host-node|ABSENT' \ + 'host-controller|ABSENT' \ + 'host-service|ABSENT' \ + 'host-service-cidr|ABSENT' >>"${raw_file}" + fi + + if ! bounded 10 ip -o -4 addr show | + LC_ALL=C run_child sort | + run_child awk '{print "host-interface|" $0}' >>"${raw_file}"; then + fail 'host interface observation unavailable' + return 1 + fi + if ! bounded 10 ip -4 route show table all | + LC_ALL=C run_child sort | + run_child awk '{print "host-route|" $0}' >>"${raw_file}"; then + fail 'host route observation unavailable' + return 1 + fi + capture_multipass_inventory "${inventory_file}" || return 1 + LC_ALL=C run_child sort "${inventory_file}" | + run_child awk '{print "multipass-inventory|" $0}' >>"${raw_file}" + append_lab_inventory_projection "${inventory_file}" "${raw_file}" || return 1 + LC_ALL=C run_child sort "${raw_file}" >"${destination}" + run_child chmod 0600 -- "${destination}" +} + +ipv4_to_integer() { + local address="$1" + local first second third fourth + IFS=. read -r first second third fourth <<<"${address}" + [[ "${first}" =~ ^[0-9]+$ && "${second}" =~ ^[0-9]+$ && + "${third}" =~ ^[0-9]+$ && "${fourth}" =~ ^[0-9]+$ ]] || return 1 + ((first <= 255 && second <= 255 && third <= 255 && fourth <= 255)) || return 1 + printf '%u\n' "$(((first << 24) | (second << 16) | (third << 8) | fourth))" +} + +resolve_host_service_cidrs() { + local destination="$1" + local configured="${REDIS_LAB_HOST_SERVICE_CIDRS:-}" + local cidr address prefix address_integer mask + local count=0 + + if [[ -z "${configured}" || + ! "${configured}" =~ ^[0-9.,/[:space:]]+$ ]]; then + fail 'host service CIDRs required' + return 1 + fi + if ! run_child tr ', ' '\n\n' <<<"${configured}" | + run_child awk 'NF {print}' | + LC_ALL=C run_child sort -u >"${destination}"; then + fail 'host service CIDRs invalid' + return 1 + fi + while IFS= read -r cidr; do + ((count += 1)) + if ((count > 16)) || [[ "${cidr}" != */* ]]; then + fail 'host service CIDRs invalid' + return 1 + fi + address="${cidr%/*}" + prefix="${cidr#*/}" + if [[ ! "${prefix}" =~ ^[0-9]+$ ]] || + ((prefix < 1 || prefix > 32)); then + fail 'host service CIDRs invalid' + return 1 + fi + if ! address_integer="$(ipv4_to_integer "${address}")"; then + fail 'host service CIDRs invalid' + return 1 + fi + mask=$(((0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF)) + if ((address_integer != (address_integer & mask))); then + fail 'host service CIDRs invalid' + return 1 + fi + done <"${destination}" + if ((count == 0)); then + fail 'host service CIDRs required' + return 1 + fi + run_child chmod 0600 -- "${destination}" +} + +cidr_overlaps() { + local candidate="$1" + local lab_cidr="$2" + local candidate_address="${candidate%/*}" + local candidate_prefix=32 + local lab_address="${lab_cidr%/*}" + local lab_prefix="${lab_cidr#*/}" + local candidate_integer lab_integer candidate_mask lab_mask + local candidate_start candidate_end lab_start lab_end + + [[ "${candidate}" == */* ]] && candidate_prefix="${candidate#*/}" + [[ "${candidate_prefix}" =~ ^[0-9]+$ && "${lab_prefix}" =~ ^[0-9]+$ ]] || return 1 + ((candidate_prefix >= 1 && candidate_prefix <= 32)) || return 1 + candidate_integer="$(ipv4_to_integer "${candidate_address}")" || return 1 + lab_integer="$(ipv4_to_integer "${lab_address}")" || return 1 + candidate_mask=$(((0xFFFFFFFF << (32 - candidate_prefix)) & 0xFFFFFFFF)) + lab_mask=$(((0xFFFFFFFF << (32 - lab_prefix)) & 0xFFFFFFFF)) + candidate_start=$((candidate_integer & candidate_mask)) + candidate_end=$((candidate_start | (0xFFFFFFFF ^ candidate_mask))) + lab_start=$((lab_integer & lab_mask)) + lab_end=$((lab_start | (0xFFFFFFFF ^ lab_mask))) + ((candidate_start <= lab_end && lab_start <= candidate_end)) +} + +reject_cidr_overlap() { + local cidr_inputs="${OBSERVATION_ROOT}/cidr-inputs.raw" + local host_context='' + local candidate + + : >"${cidr_inputs}" + run_child chmod 0600 -- "${cidr_inputs}" + if ! bounded 10 ip -o -4 addr show >>"${cidr_inputs}"; then + fail 'host interface observation unavailable' + return 1 + fi + if ! bounded 10 ip -4 route show table all >>"${cidr_inputs}"; then + fail 'host route observation unavailable' + return 1 + fi + if ! bounded "${MULTIPASS_LIST_TIMEOUT_SECONDS}" \ + multipass list --format csv >>"${cidr_inputs}"; then + fail 'multipass inventory unavailable' + return 1 + fi + if [[ -f "${HOST_KUBECONFIG}" ]]; then + if ! host_context="$(host_context_from_copy)"; then + fail 'host kubeconfig observation unavailable' + return 1 + fi + validate_context_name "${host_context}" || return 1 + if ! host_kubectl "${host_context}" get nodes \ + -o 'jsonpath={range .items[*]}{range .spec.podCIDRs[*]}{.}{"\n"}{end}{end}' \ + >>"${cidr_inputs}"; then + fail 'host pod CIDR observation unavailable' + return 1 + fi + if ! resolve_host_service_cidrs "${OBSERVATION_ROOT}/host-service-cidrs"; then + return 1 + fi + run_child cat "${OBSERVATION_ROOT}/host-service-cidrs" >>"${cidr_inputs}" + fi + + while IFS= read -r candidate; do + if cidr_overlaps "${candidate}" "${POD_CIDR}" || + cidr_overlaps "${candidate}" "${SERVICE_CIDR}"; then + fail 'host CIDR overlap' + return 1 + fi + done < <( + run_child grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?' "${cidr_inputs}" | + LC_ALL=C run_child sort -u || true + ) +} + +preflight() { + if ! prepare_runtime; then + return 1 + fi + if [[ -f "${STATE_FILE}" ]]; then + if ! validate_recorded_state; then + return 1 + fi + if recorded_state_has_instances; then + fail 'existing run state requires down' + return 1 + fi + fi + if ! reject_existing_names; then + return 1 + fi + if ! start_new_run; then + cleanup_host_kubeconfig + return 1 + fi + if ! capture_host_fingerprint before; then + cleanup_host_kubeconfig + return 1 + fi + if ! reject_cidr_overlap; then + cleanup_host_kubeconfig + return 1 + fi + cleanup_host_kubeconfig +} + +render_cloud_init() { + local name="$1" + local destination="${CLOUD_INIT_ROOT}/${name}.yaml" + local destination_next="${destination}.next" + + if ! is_allowlisted_name "${name}" || [[ -z "${RUN_ID}" ]]; then + fail 'run identity unavailable' + return 1 + fi + if ! validate_static_contract || + ! run_child mkdir -p -- "${CLOUD_INIT_ROOT}" || + ! run_child chmod 0700 -- "${CLOUD_INIT_ROOT}" || + ! validate_static_contract; then + fail 'rendered cloud-init unavailable' + return 1 + fi + if [[ -L "${destination}" || -L "${destination_next}" ]]; then + fail 'invalid transient path' + return 1 + fi + if ! run_child awk -v ownership="${RUN_ID}|${name}" ' + $0 == "runcmd:" { + print " - path: /var/lib/ca-redis-lab/ownership" + print " owner: root:root" + print " permissions: \"0600\"" + print " content: |" + print " " ownership + inserted += 1 + } + { + print + } + END { + if (inserted != 1) { + exit 1 + } + } + ' "${CLOUD_INIT_FILE}" >"${destination_next}"; then + run_child rm -f -- "${destination_next}" + fail 'rendered cloud-init unavailable' + return 1 + fi + if ! run_child chmod 0600 -- "${destination_next}" || + ! run_child mv -- "${destination_next}" "${destination}" || + ! validate_static_contract; then + run_child rm -f -- "${destination_next}" + fail 'rendered cloud-init unavailable' + return 1 + fi + printf '%s\n' "${destination}" +} + +launch_one() { + local name="$1" + local memory="$2" + local rendered_cloud_init + + if ! is_allowlisted_name "${name}"; then + fail 'invalid lab instance name' + return 1 + fi + if ! rendered_cloud_init="$(render_cloud_init "${name}" 9>&-)"; then + return 1 + fi + if ! reserve_attempt_name "${name}"; then + return 1 + fi + RUN_OWNERSHIP_ACTIVE=1 + if ! bounded "${MULTIPASS_LAUNCH_TIMEOUT_SECONDS}" multipass launch \ + --name "${name}" \ + --cpus 2 \ + --memory "${memory}" \ + --disk 12G \ + --cloud-init "${rendered_cloud_init}" \ + "${MULTIPASS_IMAGE}"; then + fail 'lab instance launch failed' + cleanup_failed_attempt "${name}" || true + return 1 + fi + if ! wait_for_owned_instance "${RUN_ID}" "${name}"; then + fail 'lab instance ownership unavailable' + cleanup_failed_attempt "${name}" || true + return 1 + fi + if ! promote_attempt_name "${name}"; then + cleanup_failed_attempt "${name}" || true + return 1 + fi +} + +server_ipv4() { + bounded "${MULTIPASS_INFO_TIMEOUT_SECONDS}" \ + multipass info --format csv "${SERVER_NAME}" | + run_child awk -F, 'NR == 2 {print $3; found = 1; exit} END {if (!found) exit 1}' +} + +prepare_k3s_binary() { + local architecture + local actual_sha256 + local name + + if ! architecture="$(bounded 5 uname -m)" || [[ "${architecture}" != x86_64 ]]; then + fail 'unsupported lab architecture' + return 1 + fi + if ! bounded 120 curl \ + --fail \ + --location \ + --silent \ + --show-error \ + --output "${K3S_BINARY}" \ + "${K3S_AMD64_URL}"; then + fail 'pinned artifact download failed' + return 1 + fi + if ! actual_sha256="$( + bounded 30 sha256sum "${K3S_BINARY}" | + run_child awk '{print $1}' + )"; then + fail 'pinned artifact verification failed' + return 1 + fi + if [[ "${actual_sha256}" != "${K3S_AMD64_SHA256}" ]]; then + run_child rm -f -- "${K3S_BINARY}" + fail 'pinned artifact verification failed' + return 1 + fi + if ! run_child chmod 0700 -- "${K3S_BINARY}"; then + fail 'pinned artifact unavailable' + return 1 + fi + for name in "${SERVER_NAME}" "${AGENT_ONE_NAME}" "${AGENT_TWO_NAME}"; do + if ! bounded 60 multipass transfer \ + "${K3S_BINARY}" "${name}:/home/ubuntu/ca-redis-lab-k3s"; then + fail 'pinned artifact transfer failed' + return 1 + fi + done +} + +render_lab_kubeconfig() { + local source_file="$1" + local destination_file="$2" + local server_address="$3" + local render_next="${destination_file}.next" + + if ! run_child rm -f -- "${render_next}"; then + run_child rm -f -- "${render_next}" "${destination_file}" || true + fail 'lab kubeconfig invalid' + return 1 + fi + if ! run_child awk -v address="${server_address}" -v target="${CONTEXT_NAME}" \ + -f "${KUBECONFIG_RENDERER}" "${source_file}" >"${render_next}"; then + run_child rm -f -- "${render_next}" "${destination_file}" || true + fail 'lab kubeconfig invalid' + return 1 + fi + if ! run_child chmod 0600 -- "${render_next}"; then + run_child rm -f -- "${render_next}" "${destination_file}" || true + fail 'lab kubeconfig invalid' + return 1 + fi + if ! run_child mv -f -- "${render_next}" "${destination_file}"; then + run_child rm -f -- "${render_next}" "${destination_file}" || true + fail 'lab kubeconfig invalid' + return 1 + fi +} + +configure_k3s() { + local server_address + local rendered_kubeconfig="${LAB_ROOT}/kubeconfig.rendered" + local server_exec="server --cluster-cidr=${POD_CIDR} --service-cidr=${SERVICE_CIDR} --disable=traefik --disable=servicelb --write-kubeconfig-mode=0600" + local agent_exec='agent' + + if ! prepare_k3s_binary; then + return 1 + fi + if ! bounded 10 openssl rand -hex 32 >"${TOKEN_FILE}"; then + fail 'lab token generation failed' + return 1 + fi + run_child chmod 0600 -- "${TOKEN_FILE}" + if ! server_address="$(server_ipv4)"; then + fail 'lab server address unavailable' + return 1 + fi + if [[ ! "${server_address}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + fail 'lab server address unavailable' + return 1 + fi + if ! ipv4_to_integer "${server_address}" >/dev/null; then + fail 'lab server address unavailable' + return 1 + fi + + if ! bounded "${MULTIPASS_EXEC_TIMEOUT_SECONDS}" \ + multipass exec "${SERVER_NAME}" -- sh -ceu \ + 'IFS= read -r K3S_TOKEN; expected_sha="$1"; expected_version="$2"; shift 2; actual_sha="$(sha256sum /home/ubuntu/ca-redis-lab-k3s | awk '"'"'{print $1}'"'"')"; test "${actual_sha}" = "${expected_sha}"; sudo install -m 0755 /home/ubuntu/ca-redis-lab-k3s /usr/local/bin/k3s; actual_version="$(sudo /usr/local/bin/k3s --version | awk '"'"'NR == 1 {print $3}'"'"')"; test "${actual_version}" = "${expected_version}"; export K3S_TOKEN; sudo -E sh -ceu '"'"'nohup /usr/local/bin/k3s "$@" >/var/log/ca-redis-lab-k3s.log 2>&1 &'"'"' sh "$@"' \ + install-k3s-server "${K3S_AMD64_SHA256}" "${K3S_VERSION}" ${server_exec} \ + <"${TOKEN_FILE}"; then + fail 'k3s server setup failed' + return 1 + fi + if ! bounded "${MULTIPASS_EXEC_TIMEOUT_SECONDS}" \ + multipass exec "${AGENT_ONE_NAME}" -- sh -ceu \ + 'IFS= read -r K3S_TOKEN; K3S_URL="$1"; expected_sha="$2"; expected_version="$3"; shift 3; actual_sha="$(sha256sum /home/ubuntu/ca-redis-lab-k3s | awk '"'"'{print $1}'"'"')"; test "${actual_sha}" = "${expected_sha}"; sudo install -m 0755 /home/ubuntu/ca-redis-lab-k3s /usr/local/bin/k3s; actual_version="$(sudo /usr/local/bin/k3s --version | awk '"'"'NR == 1 {print $3}'"'"')"; test "${actual_version}" = "${expected_version}"; export K3S_TOKEN K3S_URL; sudo -E sh -ceu '"'"'nohup /usr/local/bin/k3s "$@" >/var/log/ca-redis-lab-k3s.log 2>&1 &'"'"' sh "$@"' \ + install-k3s-agent "https://${server_address}:6443" "${K3S_AMD64_SHA256}" \ + "${K3S_VERSION}" ${agent_exec} <"${TOKEN_FILE}"; then + fail 'k3s agent setup failed' + return 1 + fi + if ! bounded "${MULTIPASS_EXEC_TIMEOUT_SECONDS}" \ + multipass exec "${AGENT_TWO_NAME}" -- sh -ceu \ + 'IFS= read -r K3S_TOKEN; K3S_URL="$1"; expected_sha="$2"; expected_version="$3"; shift 3; actual_sha="$(sha256sum /home/ubuntu/ca-redis-lab-k3s | awk '"'"'{print $1}'"'"')"; test "${actual_sha}" = "${expected_sha}"; sudo install -m 0755 /home/ubuntu/ca-redis-lab-k3s /usr/local/bin/k3s; actual_version="$(sudo /usr/local/bin/k3s --version | awk '"'"'NR == 1 {print $3}'"'"')"; test "${actual_version}" = "${expected_version}"; export K3S_TOKEN K3S_URL; sudo -E sh -ceu '"'"'nohup /usr/local/bin/k3s "$@" >/var/log/ca-redis-lab-k3s.log 2>&1 &'"'"' sh "$@"' \ + install-k3s-agent "https://${server_address}:6443" "${K3S_AMD64_SHA256}" \ + "${K3S_VERSION}" ${agent_exec} <"${TOKEN_FILE}"; then + fail 'k3s agent setup failed' + return 1 + fi + if ! bounded "${MULTIPASS_EXEC_TIMEOUT_SECONDS}" \ + multipass exec "${SERVER_NAME}" -- sh -ceu \ + 'sudo cat /etc/rancher/k3s/k3s.yaml' >"${rendered_kubeconfig}"; then + fail 'lab kubeconfig unavailable' + return 1 + fi + if [[ ! -s "${rendered_kubeconfig}" ]]; then + fail 'lab kubeconfig unavailable' + return 1 + fi + if ! render_lab_kubeconfig \ + "${rendered_kubeconfig}" "${LAB_KUBECONFIG}" "${server_address}"; then + return 1 + fi + run_child rm -f -- "${rendered_kubeconfig}" + wait_for_exact_nodes_ready +} + +wait_for_exact_nodes_ready() { + local attempts="${READY_ATTEMPTS}" + local interval="${READY_INTERVAL_SECONDS}" + local attempt + local observed="${OBSERVATION_ROOT}/lab-node-readiness.raw" + local sorted_observed="${OBSERVATION_ROOT}/lab-node-readiness.sorted" + local expected="${OBSERVATION_ROOT}/lab-node-readiness.expected" + + if [[ "${REDIS_LAB_CONTRACT_TEST:-0}" == 1 ]]; then + attempts="${REDIS_LAB_TEST_READY_ATTEMPTS:-3}" + interval=0 + if [[ ! "${attempts}" =~ ^[1-9][0-9]*$ ]] || + ((attempts > 10)); then + fail 'invalid readiness test seam' + return 1 + fi + fi + printf '%s\n' \ + "${SERVER_NAME}|True" \ + "${AGENT_ONE_NAME}|True" \ + "${AGENT_TWO_NAME}|True" | + LC_ALL=C run_child sort >"${expected}" + run_child chmod 0600 -- "${expected}" + + for ((attempt = 1; attempt <= attempts; attempt += 1)); do + if lab_kubectl get nodes \ + -o 'jsonpath={range .items[*]}{.metadata.name}{"|"}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\n"}{end}' \ + >"${observed}"; then + LC_ALL=C run_child sort -u "${observed}" >"${sorted_observed}" + if run_child cmp -s -- "${expected}" "${sorted_observed}"; then + return 0 + fi + fi + run_child sleep "${interval}" + done + fail 'lab nodes not ready' +} + +up() { + preflight || return 1 + if ! launch_one "${SERVER_NAME}" 3G; then + cleanup_recorded || true + return 1 + fi + if ! launch_one "${AGENT_ONE_NAME}" 2560M; then + cleanup_recorded || true + return 1 + fi + if ! launch_one "${AGENT_TWO_NAME}" 2560M; then + cleanup_recorded || true + return 1 + fi + if ! configure_k3s; then + cleanup_recorded || true + return 1 + fi + RUN_OWNERSHIP_ACTIVE=0 +} + +down() { + if ! prepare_runtime; then + return 1 + fi + if ! cleanup_recorded; then + fail 'lab cleanup failed' + return 1 + fi +} + +postflight() { + if ! prepare_runtime; then + return 1 + fi + if [[ ! -f "${LAB_ROOT}/fingerprint.before" ]]; then + fail 'preflight fingerprint unavailable' + return 1 + fi + if ! capture_host_fingerprint after; then + cleanup_host_kubeconfig + return 1 + fi + cleanup_host_kubeconfig + if ! run_child cmp -s -- \ + "${LAB_ROOT}/fingerprint.before" "${LAB_ROOT}/fingerprint.after"; then + fail 'host fingerprint mismatch' + return 1 + fi +} + +signal_run_handoff_for_contract() { + if [[ "${REDIS_LAB_CONTRACT_TEST:-0}" != 1 || + "${REDIS_LAB_FAKE_RUN_HANDOFF_SIGNAL:-0}" != 1 ]]; then + return 0 + fi + if [[ -L "${RUN_HANDOFF_MARKER}" ]] || + ! printf '%s\n' 'post-up-pre-command' >"${RUN_HANDOFF_MARKER}" || + ! run_child chmod 0600 -- "${RUN_HANDOFF_MARKER}"; then + fail 'run handoff test seam unavailable' + return 1 + fi + kill -TERM "${BASHPID}" + return 143 +} + +run_flow() ( + local retain_on_failure=0 + local run_status=0 + local cleanup_status=0 + local postflight_status=0 + local cleanup_required=0 + local run_state_owned=0 + + if [[ "${1:-}" == '--retain-on-failure' ]]; then + retain_on_failure=1 + shift + fi + if [[ "${1:-}" != '--' ]]; then + usage + return $? + fi + shift + if (($# == 0)); then + usage + return $? + fi + if ((retain_on_failure == 1)) && [[ "${CI:-false}" == 'true' ]]; then + fail 'retain-on-failure is forbidden in CI' + return 1 + fi + + emergency_cleanup() { + local observed_status=$? + local original_status="${1:-${observed_status}}" + trap - EXIT HUP INT TERM + cleanup_host_kubeconfig + if ((cleanup_required == 1 && run_state_owned == 1)); then + cleanup_recorded || true + fi + exit "${original_status}" + } + trap 'emergency_cleanup $?' EXIT + trap 'emergency_cleanup 129' HUP + trap 'emergency_cleanup 130' INT + trap 'emergency_cleanup 143' TERM + + cleanup_required=1 + up || return 1 + signal_run_handoff_for_contract || return $? + RUN_OWNERSHIP_ACTIVE=1 + if "$@" 9>&-; then + run_status=0 + else + run_status=$? + fi + + if ((run_status != 0 && retain_on_failure == 1)); then + RUN_OWNERSHIP_ACTIVE=0 + cleanup_required=0 + return "${run_status}" + fi + cleanup_recorded || cleanup_status=$? + if ((cleanup_status == 0)); then + cleanup_required=0 + fi + RUN_OWNERSHIP_ACTIVE=0 + capture_host_fingerprint after || postflight_status=$? + cleanup_host_kubeconfig + if ((postflight_status == 0)); then + run_child cmp -s -- \ + "${LAB_ROOT}/fingerprint.before" "${LAB_ROOT}/fingerprint.after" || + postflight_status=1 + fi + if ((cleanup_status != 0)); then + fail 'lab cleanup failed' + return 1 + fi + if ((postflight_status != 0)); then + fail 'host fingerprint mismatch' + return 1 + fi + return "${run_status}" +) + +main() { + local command_name="${1:-}" + if (($# == 0)); then + usage + return $? + fi + if ! validate_and_load_tracked_inputs; then + return 1 + fi + if ! acquire_lifecycle_lock; then + return 1 + fi + shift + case "${command_name}" in + preflight) + if (($# != 0)); then + usage + return $? + fi + preflight + ;; + up) + if (($# != 0)); then + usage + return $? + fi + up + ;; + down) + if (($# != 0)); then + usage + return $? + fi + down + ;; + postflight) + if (($# != 0)); then + usage + return $? + fi + postflight + ;; + run) + run_flow "$@" + ;; + *) + usage + ;; + esac +} + +emergency_exit() { + local observed_status=$? + local original_status="${1:-${observed_status}}" + trap - EXIT HUP INT TERM + if ((TRACKED_INPUTS_VALIDATED == 1)); then + cleanup_host_kubeconfig + if ((RUN_OWNERSHIP_ACTIVE == 1)); then + cleanup_recorded || true + fi + fi + exit "${original_status}" +} + +trap 'emergency_exit $?' EXIT +trap 'emergency_exit 129' HUP +trap 'emergency_exit 130' INT +trap 'emergency_exit 143' TERM +main "$@" diff --git a/infra/redis-lab/cloud-init/node.yaml b/infra/redis-lab/cloud-init/node.yaml new file mode 100644 index 0000000..7a046f9 --- /dev/null +++ b/infra/redis-lab/cloud-init/node.yaml @@ -0,0 +1,14 @@ +#cloud-config +package_update: false +package_upgrade: false +ssh_pwauth: false +disable_root: true +write_files: + - path: /etc/sysctl.d/90-ca-redis-lab.conf + owner: root:root + permissions: "0644" + content: | + net.ipv4.ip_forward=1 +runcmd: + - [mkdir, -p, /etc/rancher/k3s] + - [sysctl, --system] diff --git a/infra/redis-lab/lib/render-kubeconfig.awk b/infra/redis-lab/lib/render-kubeconfig.awk new file mode 100644 index 0000000..3366894 --- /dev/null +++ b/infra/redis-lab/lib/render-kubeconfig.awk @@ -0,0 +1,194 @@ +BEGIN { + state = "start" + invalid = 0 + output_count = 0 + + if (target != "ca-redis-lab") { + invalid = 1 + } +} + +function remember(line) { + output[++output_count] = line +} + +function is_credential_line(line, prefix, value) { + if (index(line, prefix) != 1) { + return 0 + } + value = substr(line, length(prefix) + 1) + return value ~ /^[A-Za-z0-9+\/=_-]+$/ +} + +function is_namespace_line(line, value) { + if (index(line, " namespace: ") != 1) { + return 0 + } + value = substr(line, length(" namespace: ") + 1) + return value ~ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/ +} + +{ + if (invalid) { + next + } + if (index($0, "\t") != 0 || index($0, "\r") != 0 || + $0 ~ /^[[:space:]]*(---|\.\.\.)([[:space:]]|$)/) { + invalid = 1 + next + } + if ($0 != "preferences: {}" && + (index($0, "{") != 0 || index($0, "}") != 0 || + index($0, "[") != 0 || index($0, "]") != 0)) { + invalid = 1 + next + } + + if (state == "start" && $0 == "apiVersion: v1") { + api_version_count += 1 + state = "apiVersion" + remember($0) + next + } + if (state == "apiVersion" && $0 == "clusters:") { + clusters_count += 1 + state = "clusters" + remember($0) + next + } + if (state == "clusters" && $0 == "- cluster:") { + cluster_item_count += 1 + state = "cluster-item" + remember($0) + next + } + if (state == "cluster-item" && + is_credential_line($0, " certificate-authority-data: ")) { + ca_data_count += 1 + state = "ca-data" + remember($0) + next + } + if (state == "ca-data" && + $0 == " server: https://127.0.0.1:6443") { + server_count += 1 + state = "server" + remember(" server: https://" address ":6443") + next + } + if (state == "server" && $0 == " name: default") { + cluster_name_count += 1 + state = "cluster-name" + remember(" name: " target) + next + } + if (state == "cluster-name" && $0 == "contexts:") { + contexts_count += 1 + state = "contexts" + remember($0) + next + } + if (state == "contexts" && $0 == "- context:") { + context_item_count += 1 + state = "context-item" + remember($0) + next + } + if (state == "context-item" && $0 == " cluster: default") { + context_cluster_count += 1 + state = "context-cluster" + remember(" cluster: " target) + next + } + if (state == "context-cluster" && is_namespace_line($0)) { + namespace_count += 1 + state = "optional-namespace" + remember($0) + next + } + if ((state == "context-cluster" || state == "optional-namespace") && + $0 == " user: default") { + context_user_count += 1 + state = "context-user" + remember(" user: " target) + next + } + if (state == "context-user" && $0 == " name: default") { + context_name_count += 1 + state = "context-name" + remember(" name: " target) + next + } + if (state == "context-name" && $0 == "current-context: default") { + current_context_count += 1 + state = "current-context" + remember("current-context: " target) + next + } + if (state == "current-context" && $0 == "kind: Config") { + kind_count += 1 + state = "kind" + remember($0) + next + } + if (state == "kind" && $0 == "preferences: {}") { + preferences_count += 1 + state = "preferences" + remember($0) + next + } + if (state == "preferences" && $0 == "users:") { + users_count += 1 + state = "users" + remember($0) + next + } + if (state == "users" && $0 == "- name: default") { + user_name_count += 1 + state = "user-name" + remember("- name: " target) + next + } + if (state == "user-name" && $0 == " user:") { + user_body_count += 1 + state = "user-body" + remember($0) + next + } + if (state == "user-body" && + is_credential_line($0, " client-certificate-data: ")) { + client_cert_count += 1 + state = "client-cert" + remember($0) + next + } + if (state == "client-cert" && + is_credential_line($0, " client-key-data: ")) { + client_key_count += 1 + state = "client-key" + remember($0) + next + } + + invalid = 1 +} + +END { + if (invalid || state != "client-key" || + api_version_count != 1 || clusters_count != 1 || + cluster_item_count != 1 || ca_data_count != 1 || + server_count != 1 || cluster_name_count != 1 || + contexts_count != 1 || context_item_count != 1 || + context_cluster_count != 1 || namespace_count > 1 || + context_user_count != 1 || context_name_count != 1 || + current_context_count != 1 || kind_count != 1 || + preferences_count != 1 || users_count != 1 || + user_name_count != 1 || user_body_count != 1 || + client_cert_count != 1 || client_key_count != 1) { + exit 1 + } + + for (line_number = 1; line_number <= output_count; line_number += 1) { + print output[line_number] + } +} diff --git a/infra/redis-lab/test/fixtures/kubeconfig-with-namespace.expected.yaml b/infra/redis-lab/test/fixtures/kubeconfig-with-namespace.expected.yaml new file mode 100644 index 0000000..26941ca --- /dev/null +++ b/infra/redis-lab/test/fixtures/kubeconfig-with-namespace.expected.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: preserve-default-ca-canary + server: https://192.0.2.10:6443 + name: ca-redis-lab +contexts: +- context: + cluster: ca-redis-lab + namespace: team-default + user: ca-redis-lab + name: ca-redis-lab +current-context: ca-redis-lab +kind: Config +preferences: {} +users: +- name: ca-redis-lab + user: + client-certificate-data: preserve-default-client-cert-canary + client-key-data: preserve-default-client-key-canary diff --git a/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.expected.yaml b/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.expected.yaml new file mode 100644 index 0000000..c71f9bd --- /dev/null +++ b/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.expected.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: preserve-default-ca-canary + server: https://192.0.2.10:6443 + name: ca-redis-lab +contexts: +- context: + cluster: ca-redis-lab + user: ca-redis-lab + name: ca-redis-lab +current-context: ca-redis-lab +kind: Config +preferences: {} +users: +- name: ca-redis-lab + user: + client-certificate-data: preserve-default-client-cert-canary + client-key-data: preserve-default-client-key-canary diff --git a/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.source.yaml b/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.source.yaml new file mode 100644 index 0000000..1e1e534 --- /dev/null +++ b/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.source.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: preserve-default-ca-canary + server: https://127.0.0.1:6443 + name: default +contexts: +- context: + cluster: default + user: default + name: default +current-context: default +kind: Config +preferences: {} +users: +- name: default + user: + client-certificate-data: preserve-default-client-cert-canary + client-key-data: preserve-default-client-key-canary diff --git a/infra/redis-lab/test/redis-lab-contract.sh b/infra/redis-lab/test/redis-lab-contract.sh new file mode 100755 index 0000000..7b058b4 --- /dev/null +++ b/infra/redis-lab/test/redis-lab-contract.sh @@ -0,0 +1,1885 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +SOURCE_REPOSITORY_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd -P)" +ACTUAL_RUNTIME_ROOT="${SOURCE_REPOSITORY_ROOT}/src/build/redis-lab" +FIXTURE_ROOT="${SOURCE_REPOSITORY_ROOT}/src/build/redis-lab-contract" +REPOSITORY_ROOT="${FIXTURE_ROOT}/repository" +LAB_SCRIPT="${REPOSITORY_ROOT}/infra/redis-lab/bin/redis-lab" +RUNTIME_ROOT="${REPOSITORY_ROOT}/src/build/redis-lab" +FAKE_BIN="${FIXTURE_ROOT}/bin" +FAKE_LOG="${FIXTURE_ROOT}/commands.log" +FAKE_INVENTORY="${FIXTURE_ROOT}/inventory" +FAKE_OWNERSHIP="${FIXTURE_ROOT}/ownership" +FAKE_LATE_CREATE="${FIXTURE_ROOT}/late-create" +FAKE_INFO_COUNT="${FIXTURE_ROOT}/info-count" +FAKE_VIOLATIONS="${FIXTURE_ROOT}/violations" +EXPECTED_STATE="${RUNTIME_ROOT}/run.state" +EXPECTED_KUBECONFIG="${RUNTIME_ROOT}/kubeconfig" +EXPECTED_HOST_KUBECONFIG="${RUNTIME_ROOT}/observations/host-kubeconfig" +EXPECTED_CLOUD_INIT_ROOT="${RUNTIME_ROOT}/cloud-init" +EXPECTED_RUN_HANDOFF_MARKER="${RUNTIME_ROOT}/run-handoff.started" +TEST_HOME_ROOT="${FIXTURE_ROOT}/home" +DEFAULT_KUBECONFIG_SNAPSHOT="${FIXTURE_ROOT}/default-kubeconfig.snapshot" +ACTUAL_RUNTIME_BEFORE="${FIXTURE_ROOT}/actual-runtime.before" +ACTUAL_RUNTIME_AFTER="${FIXTURE_ROOT}/actual-runtime.after" +KUBECONFIG_WITH_NAMESPACE_GOLDEN="${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/test/fixtures/kubeconfig-with-namespace.expected.yaml" +KUBECONFIG_WITHOUT_NAMESPACE_SOURCE="${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.source.yaml" +KUBECONFIG_WITHOUT_NAMESPACE_GOLDEN="${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/test/fixtures/kubeconfig-without-namespace.expected.yaml" +KUBECONFIG_WITHOUT_NAMESPACE_ACTUAL="${FIXTURE_ROOT}/kubeconfig-without-namespace.actual" + +export REDIS_LAB_FAKE_OWNERSHIP="${FAKE_OWNERSHIP}" +export REDIS_LAB_FAKE_LATE_CREATE="${FAKE_LATE_CREATE}" +export REDIS_LAB_FAKE_INFO_COUNT="${FAKE_INFO_COUNT}" +export REDIS_LAB_EXPECTED_CLOUD_INIT_ROOT="${EXPECTED_CLOUD_INIT_ROOT}" + +fail() { + printf 'redis-lab-contract: %s\n' "$1" >&2 + exit 1 +} + +assert_file_contains() { + local file="$1" + local literal="$2" + grep -Fqx -- "${literal}" "${file}" || + fail "missing expected line: ${literal}" +} + +assert_file_not_contains_pattern() { + local file="$1" + local pattern="$2" + if grep -Eq -- "${pattern}" "${file}"; then + fail "forbidden command matched: ${pattern}" + fi +} + +assert_count() { + local expected="$1" + local pattern="$2" + local file="$3" + local actual + actual="$(grep -Ec -- "${pattern}" "${file}" || true)" + [[ "${actual}" == "${expected}" ]] || + fail "expected ${expected} matches for ${pattern}, found ${actual}" +} + +assert_fails() { + if "$@"; then + fail "command unexpectedly succeeded: $*" + fi +} + +assert_fd9_static_contract() { + local lifecycle_script="$1" + local violations="${FIXTURE_ROOT}/fd9-static.violations" + local closed_timeout_count + local keep_timeout_count + local acquisition_count + local timeout_count + + /usr/bin/awk ' + function inspect() { + candidate = logical + sub(/^[[:space:]]+/, "", candidate) + if (candidate == "" || candidate ~ /^#/ || candidate ~ /^\047/) { + logical = "" + return + } + if (candidate ~ /(^|[[:space:];|!&(])(mkdir|chmod|rm|cp|mv|awk|sort|grep|tr|cat|cmp|sleep|multipass|kubectl|ip|curl|sha256sum|openssl|uname)([[:space:]]|$)/ && + candidate !~ /(^|[[:space:];|!&(])(run_child|bounded)([[:space:]]|$)/) { + print start_line ":" candidate + } + logical = "" + } + { + if (logical == "") { + start_line = NR + } + logical = logical " " $0 + if ($0 ~ /\\[[:space:]]*$/ || $0 ~ /\|[[:space:]]*$/ || + $0 ~ /\|\|[[:space:]]*$/) { + next + } + inspect() + } + END { + if (logical != "") { + inspect() + } + } + ' "${lifecycle_script}" >"${violations}" + if [[ -s "${violations}" ]]; then + IFS= read -r first_violation <"${violations}" + fail "raw post-lock child path is not FD9-closed: ${first_violation}" + fi + closed_timeout_count="$( + grep -Fxc -- \ + ' timeout --signal=TERM --kill-after=5s "${seconds}s" "$@" 9>&-' \ + "${lifecycle_script}" || true + )" + keep_timeout_count="$( + grep -Fxc -- \ + ' timeout --signal=TERM --kill-after=5s "${seconds}s" "$@"' \ + "${lifecycle_script}" || true + )" + acquisition_count="$( + grep -Fc -- 'bounded_keep_lock 5 flock -n 9' "${lifecycle_script}" || true + )" + timeout_count="$( + grep -Fc -- 'timeout --signal=TERM --kill-after=5s' "${lifecycle_script}" || true + )" + if [[ "${closed_timeout_count}" != 1 || "${keep_timeout_count}" != 1 || + "${acquisition_count}" != 1 || "${timeout_count}" != 2 ]]; then + fail 'FD9 timeout/lock acquisition inventory changed' + fi +} + +reset_case() { + : >"${FAKE_LOG}" + : >"${FAKE_INVENTORY}" + : >"${FAKE_OWNERSHIP}" + : >"${FAKE_LATE_CREATE}" + : >"${FAKE_INFO_COUNT}" + rm -rf -- \ + "${RUNTIME_ROOT}/cloud-init" \ + "${RUNTIME_ROOT}/observations" \ + "${RUNTIME_ROOT}/fingerprint.before" \ + "${RUNTIME_ROOT}/fingerprint.after" \ + "${RUNTIME_ROOT}/k3s-token" \ + "${RUNTIME_ROOT}/kubeconfig" \ + "${RUNTIME_ROOT}/kubeconfig.next" \ + "${EXPECTED_RUN_HANDOFF_MARKER}" \ + "${RUNTIME_ROOT}/run.state" + mkdir -p -- "${TEST_HOME_ROOT}/.kube" + printf '%s\n' \ + 'apiVersion: v1' \ + 'clusters:' \ + '- cluster:' \ + ' server: https://host.invalid:6443' \ + ' name: host-cluster' \ + 'contexts:' \ + '- context:' \ + ' cluster: host-cluster' \ + ' user: host-user' \ + ' name: host-context' \ + 'current-context: host-context' \ + 'kind: Config' \ + 'users:' \ + '- name: host-user' \ + ' user: {}' >"${TEST_HOME_ROOT}/.kube/config" + cp -- "${TEST_HOME_ROOT}/.kube/config" "${DEFAULT_KUBECONFIG_SNAPSHOT}" +} + +prepare_tracked_input_repository() { + local repository_root="$1" + + rm -rf -- "${repository_root}" + mkdir -p -- \ + "${repository_root}/infra/redis-lab/bin" \ + "${repository_root}/infra/redis-lab/cloud-init" \ + "${repository_root}/infra/redis-lab/lib" \ + "${repository_root}/src" + cp -- "${LAB_SCRIPT}" "${repository_root}/infra/redis-lab/bin/redis-lab" + cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" \ + "${repository_root}/infra/redis-lab/cloud-init/node.yaml" + cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" \ + "${repository_root}/infra/redis-lab/lib/render-kubeconfig.awk" + cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/versions.env" \ + "${repository_root}/infra/redis-lab/versions.env" + chmod 0700 -- "${repository_root}/infra/redis-lab/bin/redis-lab" +} + +snapshot_actual_runtime() { + local destination="$1" + local path + + if [[ ! -e "${ACTUAL_RUNTIME_ROOT}" && ! -L "${ACTUAL_RUNTIME_ROOT}" ]]; then + printf '%s\n' 'ABSENT' >"${destination}" + return + fi + { + find "${ACTUAL_RUNTIME_ROOT}" -printf '%y|%m|%p|%l\n' | LC_ALL=C sort + while IFS= read -r path; do + /usr/bin/sha256sum "${path}" + done < <(find "${ACTUAL_RUNTIME_ROOT}" -type f -print | LC_ALL=C sort) + } >"${destination}" +} + +run_lab() { + local expected_state="${RUN_LAB_EXPECTED_STATE:-${EXPECTED_STATE}}" + local expected_kubeconfig="${RUN_LAB_EXPECTED_KUBECONFIG:-${EXPECTED_KUBECONFIG}}" + local expected_host_kubeconfig="${RUN_LAB_EXPECTED_HOST_KUBECONFIG:-${EXPECTED_HOST_KUBECONFIG}}" + local expected_cloud_init_root="${RUN_LAB_EXPECTED_CLOUD_INIT_ROOT:-${EXPECTED_CLOUD_INIT_ROOT}}" + + env \ + PATH="${FAKE_BIN}" \ + REDIS_LAB_CONTRACT_TEST=1 \ + REDIS_LAB_HOST_SERVICE_CIDRS='10.81.0.0/16' \ + REDIS_LAB_HOME_DIR="${TEST_HOME_ROOT}" \ + REDIS_LAB_FAKE_LOG="${FAKE_LOG}" \ + REDIS_LAB_FAKE_INVENTORY="${FAKE_INVENTORY}" \ + REDIS_LAB_FAKE_OWNERSHIP="${FAKE_OWNERSHIP}" \ + REDIS_LAB_FAKE_LATE_CREATE="${FAKE_LATE_CREATE}" \ + REDIS_LAB_FAKE_INFO_COUNT="${FAKE_INFO_COUNT}" \ + REDIS_LAB_FAKE_VIOLATIONS="${FAKE_VIOLATIONS}" \ + REDIS_LAB_EXPECTED_STATE="${expected_state}" \ + REDIS_LAB_EXPECTED_KUBECONFIG="${expected_kubeconfig}" \ + REDIS_LAB_EXPECTED_HOST_KUBECONFIG="${expected_host_kubeconfig}" \ + REDIS_LAB_EXPECTED_CLOUD_INIT_ROOT="${expected_cloud_init_root}" \ + "$@" +} + +rm -rf -- "${FIXTURE_ROOT}" +mkdir -p -- \ + "${REPOSITORY_ROOT}/infra/redis-lab/bin" \ + "${REPOSITORY_ROOT}/infra/redis-lab/cloud-init" \ + "${REPOSITORY_ROOT}/infra/redis-lab/lib" \ + "${REPOSITORY_ROOT}/src" \ + "${FAKE_BIN}" \ + "${TEST_HOME_ROOT}/.kube" +snapshot_actual_runtime "${ACTUAL_RUNTIME_BEFORE}" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/bin/redis-lab" \ + "${REPOSITORY_ROOT}/infra/redis-lab/bin/redis-lab" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" \ + "${REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" \ + "${REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/versions.env" \ + "${REPOSITORY_ROOT}/infra/redis-lab/versions.env" +: >"${FAKE_LOG}" +: >"${FAKE_INVENTORY}" +: >"${FAKE_OWNERSHIP}" +: >"${FAKE_LATE_CREATE}" +: >"${FAKE_INFO_COUNT}" +: >"${FAKE_VIOLATIONS}" + +if ! /usr/bin/awk -v address=192.0.2.10 -v target=ca-redis-lab \ + -f "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" \ + "${KUBECONFIG_WITHOUT_NAMESPACE_SOURCE}" \ + >"${KUBECONFIG_WITHOUT_NAMESPACE_ACTUAL}"; then + fail 'namespace-absent kubeconfig was rejected' +fi +cmp -s -- \ + "${KUBECONFIG_WITHOUT_NAMESPACE_ACTUAL}" \ + "${KUBECONFIG_WITHOUT_NAMESPACE_GOLDEN}" || + fail 'namespace-absent kubeconfig output changed' + +cat >"${FAKE_BIN}/safe-command" <<'FAKE_SAFE_COMMAND' +#!/bin/bash +set -Eeuo pipefail +command_name="${0##*/}" +if [[ "${command_name}" != bash ]]; then + printf 'child <%s>\n' "${command_name}" >>"${REDIS_LAB_FAKE_LOG}" +fi +if [[ -e "/proc/${BASHPID}/fd/9" || -e /dev/fd/9 ]]; then + printf 'fd9-leak <%s>\n' "${command_name}" >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 116 +fi +exec "/usr/bin/${command_name}" "$@" +FAKE_SAFE_COMMAND +for safe_command in bash dirname awk sort grep rm mkdir cp tr cat cmp env; do + ln -s -- "${FAKE_BIN}/safe-command" "${FAKE_BIN}/${safe_command}" +done + +cat >"${FAKE_BIN}/flock" <<'FAKE_FLOCK' +#!/bin/bash +set -Eeuo pipefail +if [[ ! -e "/proc/${BASHPID}/fd/9" && ! -e /dev/fd/9 ]] || + [[ "$#" != 2 || "$1" != -n || "$2" != 9 ]]; then + printf '%s\n' 'fd9-lock-acquisition-contract' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 117 +fi +exec /usr/bin/flock "$@" +FAKE_FLOCK + +for denied_command in docker sudo systemctl k3s wget; do + cat >"${FAKE_BIN}/${denied_command}" <<'FAKE_DENIED' +#!/usr/bin/env bash +printf '%s\n' 'sealed-path-denied-command' >>"${REDIS_LAB_FAKE_VIOLATIONS}" +exit 127 +FAKE_DENIED +done + +cat >"${FAKE_BIN}/multipass" <<'FAKE_MULTIPASS' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'multipass' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" + +command_name="${1:-}" +ownership_file="${REDIS_LAB_FAKE_OWNERSHIP:-${REDIS_LAB_FAKE_INVENTORY}.ownership}" +late_create_file="${REDIS_LAB_FAKE_LATE_CREATE:-${REDIS_LAB_FAKE_INVENTORY}.late-create}" +info_count_file="${REDIS_LAB_FAKE_INFO_COUNT:-${REDIS_LAB_FAKE_INVENTORY}.info-count}" +expected_cloud_init_root="${REDIS_LAB_EXPECTED_CLOUD_INIT_ROOT:-$(dirname -- "${REDIS_LAB_EXPECTED_STATE}")/cloud-init}" + +record_ownership() { + local target_name="$1" + local marker="$2" + local ownership_next="${ownership_file}.next" + + grep -Ev "^${target_name}\\|" "${ownership_file}" >"${ownership_next}" || true + printf '%s|%s\n' "${target_name}" "${marker}" >>"${ownership_next}" + mv -- "${ownership_next}" "${ownership_file}" +} + +emit_kubeconfig() { + local variant="$1" + local swap + local -a lines=( + 'apiVersion: v1' + 'clusters:' + '- cluster:' + ' certificate-authority-data: preserve-default-ca-canary' + ' server: https://127.0.0.1:6443' + ' name: default' + 'contexts:' + '- context:' + ' cluster: default' + ' namespace: team-default' + ' user: default' + ' name: default' + 'current-context: default' + 'kind: Config' + 'preferences: {}' + 'users:' + '- name: default' + ' user:' + ' client-certificate-data: preserve-default-client-cert-canary' + ' client-key-data: preserve-default-client-key-canary' + ) + + case "${variant}" in + valid) + ;; + missing-server) + unset 'lines[4]' + ;; + duplicate-server) + lines[4]+=$'\n server: https://127.0.0.1:6443' + ;; + reordered-kind-preferences) + swap="${lines[13]}" + lines[13]="${lines[14]}" + lines[14]="${swap}" + ;; + unknown-cluster-key) + lines[4]+=$'\n proxy-url: https://proxy.invalid' + ;; + whitespace-before-colon) + lines[4]=' server : https://127.0.0.1:6443' + ;; + quoted-key) + lines[4]=' "server": https://127.0.0.1:6443' + ;; + tagged-key) + lines[4]=' !!str server: https://127.0.0.1:6443' + ;; + explicit-key) + lines[4]=$' ? server\n : https://127.0.0.1:6443' + ;; + anchor) + lines[2]='- cluster: &default-cluster' + ;; + alias) + lines[4]=' server: *loopback-server' + ;; + merge) + lines[4]+=$'\n <<: *default-cluster' + ;; + flow-map) + lines[4]=' server: {value: https://127.0.0.1:6443}' + ;; + flow-sequence) + lines[4]=' server: [https://127.0.0.1:6443]' + ;; + tab-indentation) + lines[4]=$'\tserver: https://127.0.0.1:6443' + ;; + crlf) + printf '%s\r\n' "${lines[@]}" + return + ;; + document-start) + lines[0]=$'---\napiVersion: v1' + ;; + document-end) + lines[19]+=$'\n...' + ;; + trailing-content) + lines[19]+=$'\nmetadata: forbidden' + ;; + sibling-flow-cluster) + lines[5]+=$'\n cluster : {server: https://foreign.invalid:6443}' + ;; + sibling-flow-context) + lines[11]+=$'\n context : {cluster: foreign, user: foreign}' + ;; + *) + printf '%s\n' 'multipass-kubeconfig-variant' \ + >>"${REDIS_LAB_FAKE_VIOLATIONS}" + return 115 + ;; + esac + + printf '%s\n' "${lines[@]}" +} + +case "${command_name}" in + list) + if [[ -n "${REDIS_LAB_FAKE_INFRA_BACKGROUND_PID_FILE:-}" && + ! -e "${REDIS_LAB_FAKE_INFRA_BACKGROUND_PID_FILE}" ]]; then + /bin/sleep 3 >/dev/null 2>&1 & + printf '%s\n' "$!" >"${REDIS_LAB_FAKE_INFRA_BACKGROUND_PID_FILE}" + fi + [[ "$#" == 3 && "$2" == '--format' && "$3" == 'csv' ]] || { + printf '%s\n' 'multipass-list-arguments' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 90 + } + printf '%s\n' 'Name,State,IPv4,Image' + while IFS= read -r name; do + [[ -n "${name}" ]] || continue + printf '%s\n' "${name},Running,192.0.2.10,Ubuntu 24.04 LTS" + done <"${REDIS_LAB_FAKE_INVENTORY}" + ;; + launch) + name='' + cpus='' + memory='' + disk='' + cloud_init='' + image='' + shift + while (($#)); do + case "$1" in + --name) + name="${2:-}" + shift 2 + ;; + --cpus) + cpus="${2:-}" + shift 2 + ;; + --memory) + memory="${2:-}" + shift 2 + ;; + --disk) + disk="${2:-}" + shift 2 + ;; + --cloud-init) + cloud_init="${2:-}" + shift 2 + ;; + *) + image="$1" + shift + ;; + esac + done + case "${name}:${cpus}:${memory}:${disk}" in + ca-redis-lab-server:2:3G:12G | \ + ca-redis-lab-agent-1:2:2560M:12G | \ + ca-redis-lab-agent-2:2:2560M:12G) + ;; + *) + printf '%s\n' 'multipass-launch-contract' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 91 + ;; + esac + [[ "${image}" == '24.04' ]] || { + printf '%s\n' 'multipass-launch-inputs' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 92 + } + [[ "${cloud_init}" == "${expected_cloud_init_root}/${name}.yaml" && + "$(/usr/bin/stat -c '%a' "${cloud_init}")" == 600 ]] || { + printf '%s\n' 'multipass-rendered-cloud-init' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 111 + } + ownership_marker="$(awk -v target="${name}" ' + $0 ~ "^ run-[0-9]+-[0-9]+-[0-9]+\\|" target "$" { + sub(/^ /, "") + print + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "${cloud_init}")" || { + printf '%s\n' 'multipass-cloud-init-ownership' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 112 + } + grep -Eq "^PENDING\\|run-[0-9]+-[0-9]+-[0-9]+\\|${name}$" \ + "${REDIS_LAB_EXPECTED_STATE}" || { + printf '%s\n' 'launch-attempt-not-reserved' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 99 + } + if [[ "${name}" == 'ca-redis-lab-agent-1' ]]; then + grep -Eq '^CREATED\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-server$' \ + "${REDIS_LAB_EXPECTED_STATE}" || { + printf '%s\n' 'server-state-not-recorded-before-next-launch' \ + >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 93 + } + fi + if [[ "${name}" == 'ca-redis-lab-agent-2' ]]; then + grep -Eq '^CREATED\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-agent-1$' \ + "${REDIS_LAB_EXPECTED_STATE}" || { + printf '%s\n' 'agent-state-not-recorded-before-next-launch' \ + >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 94 + } + fi + if [[ "${REDIS_LAB_FAIL_LAUNCH_NAME:-}" == "${name}" ]]; then + exit 42 + fi + printf '%s\n' "${name}" >>"${REDIS_LAB_FAKE_INVENTORY}" + if [[ "${name}" == 'ca-redis-lab-server' && + "${REDIS_LAB_FAKE_LAUNCH_MARKER_MODE:-}" == missing ]]; then + : + elif [[ "${name}" == 'ca-redis-lab-server' && + "${REDIS_LAB_FAKE_LAUNCH_MARKER_MODE:-}" == foreign ]]; then + record_ownership "${name}" "run-999-999-999|${name}" + else + record_ownership "${name}" "${ownership_marker}" + fi + if [[ "${REDIS_LAB_FAKE_LAUNCH_SIGNAL_PARENT:-}" == 1 && + "${name}" == 'ca-redis-lab-server' ]]; then + : >"${REDIS_LAB_FAKE_LAUNCH_BARRIER}.started" + kill -TERM "${PPID}" + exit 143 + fi + if [[ -n "${REDIS_LAB_FAKE_LAUNCH_BARRIER:-}" && + "${name}" == 'ca-redis-lab-server' ]]; then + : >"${REDIS_LAB_FAKE_LAUNCH_BARRIER}.started" + barrier_attempt=0 + while [[ ! -f "${REDIS_LAB_FAKE_LAUNCH_BARRIER}.release" ]]; do + /bin/sleep 0.02 + ((barrier_attempt += 1)) + ((barrier_attempt < 250)) || exit 43 + done + fi + ;; + info) + [[ "$#" == 4 && "$2" == '--format' && "$3" == 'csv' ]] || { + printf '%s\n' 'multipass-info-arguments' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 95 + } + name="$4" + if ! grep -Fqx -- "${name}" "${REDIS_LAB_FAKE_INVENTORY}" && + [[ -n "${REDIS_LAB_FAKE_LATE_CREATE_MODE:-}" ]]; then + info_count="$(cat "${info_count_file}" 2>/dev/null || true)" + info_count="${info_count:-0}" + ((info_count += 1)) + printf '%s\n' "${info_count}" >"${info_count_file}" + if ((info_count >= 2)) && + [[ "${REDIS_LAB_FAKE_LATE_CREATE_MODE}" != absent ]]; then + IFS='|' read -r late_name late_run_id late_vm_name <"${late_create_file}" + [[ "${late_name}" == "${name}" && "${late_vm_name}" == "${name}" ]] || { + printf '%s\n' 'multipass-late-create-contract' \ + >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 113 + } + if [[ "${REDIS_LAB_FAKE_LATE_CREATE_MODE}" == foreign ]]; then + late_run_id='run-999-999-999' + fi + printf '%s\n' "${name}" >>"${REDIS_LAB_FAKE_INVENTORY}" + record_ownership "${name}" "${late_run_id}|${late_vm_name}" + fi + fi + grep -Fqx -- "${name}" "${REDIS_LAB_FAKE_INVENTORY}" || exit 2 + printf '%s\n' 'Name,State,IPv4,Release,Image hash,Load,Disk usage,Memory usage,Mounts' + if [[ "${REDIS_LAB_FAKE_BAD_SERVER_IP:-}" == 1 && + "${name}" == 'ca-redis-lab-server' ]]; then + printf '%s\n' "${name},Running,999.0.2.10,Ubuntu 24.04 LTS,fake,0.00,1G,128M,--" + else + printf '%s\n' "${name},Running,192.0.2.10,Ubuntu 24.04 LTS,fake,0.00,1G,128M,--" + fi + ;; + transfer) + [[ "$#" == 3 && -f "$2" ]] || { + printf '%s\n' 'multipass-transfer-arguments' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 100 + } + case "$3" in + ca-redis-lab-server:/home/ubuntu/ca-redis-lab-k3s | \ + ca-redis-lab-agent-1:/home/ubuntu/ca-redis-lab-k3s | \ + ca-redis-lab-agent-2:/home/ubuntu/ca-redis-lab-k3s) + ;; + *) + printf '%s\n' 'multipass-transfer-target' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 101 + ;; + esac + ;; + exec) + if [[ "$#" == 6 && "$3" == '--' && "$4" == sudo && "$5" == cat && + "$6" == '/var/lib/ca-redis-lab/ownership' ]]; then + name="$2" + ownership_marker="$(awk -F'|' -v target="${name}" ' + $1 == target { + print $2 "|" $3 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "${ownership_file}")" || exit 2 + printf '%s\n' "${ownership_marker}" + exit 0 + fi + if [[ "$*" == *'install-k3s-server'* || "$*" == *'install-k3s-agent'* ]]; then + IFS= read -r received_token || { + printf '%s\n' 'multipass-exec-token-missing' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 104 + } + [[ "${received_token}" == '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' ]] || { + printf '%s\n' 'multipass-exec-token-invalid' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 105 + } + if [[ "$*" == *'curl '* || "$*" != *'/usr/local/bin/k3s'* || + "$*" == *"${received_token}"* ]]; then + printf '%s\n' 'multipass-exec-install-contract' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 106 + fi + if [[ "$*" != *'sha256sum /home/ubuntu/ca-redis-lab-k3s'* || + "$*" != *'f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc'* ]]; then + printf '%s\n' 'multipass-exec-transferred-sha-contract' \ + >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 109 + fi + if [[ "${REDIS_LAB_FAKE_TRANSFER_SHA_MISMATCH:-}" == 1 ]]; then + exit 110 + fi + if [[ "$*" == *'install-k3s-server'* ]]; then + [[ "$*" == *'install-k3s-server f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc v1.33.3+k3s1 server --cluster-cidr=10.52.0.0/16 --service-cidr=10.53.0.0/16 --disable=traefik --disable=servicelb --write-kubeconfig-mode=0600'* ]] || { + printf '%s\n' 'multipass-exec-server-contract' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 107 + } + else + [[ "$*" == *'install-k3s-agent https://192.0.2.10:6443 f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc v1.33.3+k3s1 agent'* ]] || { + printf '%s\n' 'multipass-exec-agent-contract' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 108 + } + fi + printf 'vm-install <%s>\n' "${2:-}" >>"${REDIS_LAB_FAKE_LOG}" + fi + if [[ "$*" == *'/etc/rancher/k3s/k3s.yaml'* ]]; then + emit_kubeconfig "${REDIS_LAB_FAKE_KUBECONFIG_SCHEMA_VARIANT:-valid}" + fi + exit 0 + ;; + delete) + [[ "$#" == 3 && "$2" == '--purge' ]] || { + printf '%s\n' 'multipass-delete-arguments' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 96 + } + name="$3" + case "${name}" in + ca-redis-lab-server | ca-redis-lab-agent-1 | ca-redis-lab-agent-2) + ;; + *) + printf '%s\n' 'multipass-delete-name' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 97 + ;; + esac + if [[ "${REDIS_LAB_FAKE_DELETE_FAILURE_NAME:-}" == "${name}" ]]; then + exit 114 + fi + inventory_next="${REDIS_LAB_FAKE_INVENTORY}.next" + grep -Fvx -- "${name}" "${REDIS_LAB_FAKE_INVENTORY}" >"${inventory_next}" || true + mv -- "${inventory_next}" "${REDIS_LAB_FAKE_INVENTORY}" + ownership_next="${ownership_file}.next" + grep -Ev "^${name}\\|" "${ownership_file}" >"${ownership_next}" || true + mv -- "${ownership_next}" "${ownership_file}" + ;; + *) + printf '%s\n' 'multipass-subcommand' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 98 + ;; +esac +FAKE_MULTIPASS + +cat >"${FAKE_BIN}/kubectl" <<'FAKE_KUBECTL' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'kubectl' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" + +[[ "${KUBECONFIG+x}" != x ]] || { + printf '%s\n' 'kubectl-env-kubeconfig' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 80 +} +[[ "$#" -ge 4 && "$1" == '--kubeconfig' && "$3" == '--context' ]] || { + printf '%s\n' 'kubectl-explicit-target' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 81 +} +target='unknown' +if [[ "$2" == "${REDIS_LAB_EXPECTED_HOST_KUBECONFIG}" && + "$4" == 'host-context' ]]; then + target='host' +elif [[ "$2" == "${REDIS_LAB_EXPECTED_KUBECONFIG}" && + "$4" == 'ca-redis-lab' ]]; then + target='lab' +else + printf '%s\n' 'kubectl-explicit-target' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 81 +fi +shift 4 + +case "${1:-}" in + apply | create | delete | edit | patch | replace | rollout | scale | taint) + printf '%s\n' 'host-mutating-kubectl' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 82 + ;; + config) + case "${2:-}" in + view) + if [[ "${target}" == host ]]; then + printf '%s\n' 'https://host.invalid:6443' + else + printf '%s\n' 'https://192.0.2.10:6443' + fi + ;; + *) + printf '%s\n' 'kubectl-config-command' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 83 + ;; + esac + ;; + get) + case "${2:-}" in + nodes) + if [[ "${target}" == lab ]]; then + if [[ "${REDIS_LAB_FAKE_NOT_READY:-}" == 1 ]]; then + printf '%s\n' \ + 'ca-redis-lab-server|True' \ + 'ca-redis-lab-agent-1|False' + else + printf '%s\n' \ + 'ca-redis-lab-server|True' \ + 'ca-redis-lab-agent-1|True' \ + 'ca-redis-lab-agent-2|True' + fi + elif [[ "${REDIS_LAB_FAKE_CIDR_OVERLAP:-}" == 1 ]]; then + printf '%s\n' 'host-node|k3s://host|10.52.8.0/24' + else + printf '%s\n' 'host-node|k3s://host|10.80.0.0/24' + fi + ;; + deployments,statefulsets,daemonsets) + printf '%s\n' 'kube-system|deployment|coredns|1' + ;; + services) + printf '%s\n' 'default|kubernetes|443' + ;; + *) + printf '%s\n' 'kubectl-get-resource' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 84 + ;; + esac + ;; + *) + printf '%s\n' 'kubectl-command' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 85 + ;; +esac +FAKE_KUBECTL + +cat >"${FAKE_BIN}/ip" <<'FAKE_IP' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'ip' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +if [[ "$*" == '-o -4 addr show' ]]; then + printf '%s\n' '1: lo inet 127.0.0.1/8 scope host lo' + printf '%s\n' '2: eth0 inet 192.0.2.20/24 scope global eth0' +elif [[ "$*" == '-4 route show table all' ]]; then + printf '%s\n' 'default via 192.0.2.1 dev eth0' + printf '%s\n' '192.0.2.0/24 dev eth0 scope link' +else + printf '%s\n' 'ip-arguments' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 70 +fi +FAKE_IP + +cat >"${FAKE_BIN}/sha256sum" <<'FAKE_SHA256SUM' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'sha256sum' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +digest='1111111111111111111111111111111111111111111111111111111111111111' +if [[ "${1:-}" == */k3s-amd64 ]]; then + digest='f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' + if [[ "${REDIS_LAB_FAKE_K3S_SHA_MISMATCH:-}" == 1 ]]; then + digest='0000000000000000000000000000000000000000000000000000000000000000' + fi +fi +if [[ "${REDIS_LAB_FAKE_FINGERPRINT_MISMATCH:-}" == 1 && + "${REDIS_LAB_CAPTURE_PHASE:-}" == after ]]; then + digest='2222222222222222222222222222222222222222222222222222222222222222' +fi +printf '%s %s\n' "${digest}" "${1:-}" +FAKE_SHA256SUM + +cat >"${FAKE_BIN}/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'curl' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +destination='' +url='' +while (($#)); do + case "$1" in + --fail | --location | --silent | --show-error) + shift + ;; + --output) + destination="${2:-}" + shift 2 + ;; + *) + url="$1" + shift + ;; + esac +done +[[ -n "${destination}" && + "${url}" == 'https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' ]] || { + printf '%s\n' 'curl-pinned-artifact-contract' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 102 +} +printf '%s\n' 'fake pinned k3s amd64 binary' >"${destination}" +FAKE_CURL + +cat >"${FAKE_BIN}/uname" <<'FAKE_UNAME' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'uname' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +[[ "$#" == 1 && "$1" == '-m' ]] || exit 103 +printf '%s\n' 'x86_64' +FAKE_UNAME + +cat >"${FAKE_BIN}/timeout" <<'FAKE_TIMEOUT' +#!/bin/bash +set -Eeuo pipefail +printf 'timeout' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +while (($#)); do + case "$1" in + --signal=* | --kill-after=*) + shift + ;; + *) + shift + break + ;; + esac +done +(("$#" > 0)) || exit 64 +if [[ -e "/proc/${BASHPID}/fd/9" || -e /dev/fd/9 ]]; then + if [[ "$#" != 3 || "$1" != flock || "$2" != -n || "$3" != 9 ]]; then + printf '%s\n' 'fd9-timeout-child-leak' >>"${REDIS_LAB_FAKE_VIOLATIONS}" + exit 118 + fi +fi +if [[ "${REDIS_LAB_FAKE_TIMEOUT_COMMAND:-}" == 'multipass-launch' && + "${1:-}" == multipass && "${2:-}" == launch ]]; then + if [[ -n "${REDIS_LAB_FAKE_LATE_CREATE_MODE:-}" ]]; then + late_name='' + late_cloud_init='' + shift 2 + while (($#)); do + case "$1" in + --name) + late_name="${2:-}" + shift 2 + ;; + --cloud-init) + late_cloud_init="${2:-}" + shift 2 + ;; + --cpus | --memory | --disk) + shift 2 + ;; + *) + shift + ;; + esac + done + late_marker="$(awk -v target="${late_name}" ' + $0 ~ "^ run-[0-9]+-[0-9]+-[0-9]+\\|" target "$" { + sub(/^ /, "") + print + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "${late_cloud_init}")" + printf '%s|%s\n' "${late_name}" "${late_marker}" \ + >"${REDIS_LAB_FAKE_LATE_CREATE}" + fi + exit 124 +fi +exec "$@" +FAKE_TIMEOUT + +cat >"${FAKE_BIN}/sleep" <<'FAKE_SLEEP' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'sleep' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +exit 0 +FAKE_SLEEP + +cat >"${FAKE_BIN}/mv" <<'FAKE_MV' +#!/usr/bin/env bash +set -Eeuo pipefail +source_path="${@: -2:1}" +destination="${@: -1}" +if [[ "${destination}" == "${REDIS_LAB_EXPECTED_STATE}" && + -f "${source_path}" ]]; then + if [[ "${REDIS_LAB_FAIL_STATE_RESERVATION:-}" == 1 ]] && + grep -Fq 'PENDING|' "${source_path}"; then + exit 51 + fi + if [[ "${REDIS_LAB_FAIL_STATE_PROMOTION:-}" == 1 ]] && + grep -Fq 'CREATED|' "${source_path}"; then + exit 52 + fi +fi +if [[ "${REDIS_LAB_FAIL_KUBECONFIG_MV:-}" == 1 && + "${source_path}" == "${REDIS_LAB_EXPECTED_KUBECONFIG}.next" && + "${destination}" == "${REDIS_LAB_EXPECTED_KUBECONFIG}" ]]; then + exit 54 +fi +exec /usr/bin/mv "$@" +FAKE_MV + +cat >"${FAKE_BIN}/chmod" <<'FAKE_CHMOD' +#!/usr/bin/env bash +set -Eeuo pipefail +target="${@: -1}" +if [[ -n "${REDIS_LAB_FAKE_DIRECT_TOOL_BACKGROUND_PID_FILE:-}" && + "${target}" == */redis-lab/observations && + ! -e "${REDIS_LAB_FAKE_DIRECT_TOOL_BACKGROUND_PID_FILE}" ]]; then + /bin/sleep 3 >/dev/null 2>&1 & + printf '%s\n' "$!" >"${REDIS_LAB_FAKE_DIRECT_TOOL_BACKGROUND_PID_FILE}" +fi +if [[ "${REDIS_LAB_FAIL_STATE_CHMOD:-}" == 1 && + "${target}" == "${REDIS_LAB_EXPECTED_STATE}.next" && + -f "${target}" ]] && + grep -Fq 'CREATED|' "${target}"; then + exit 53 +fi +if [[ "${REDIS_LAB_FAIL_KUBECONFIG_CHMOD:-}" == 1 && + "${target}" == "${REDIS_LAB_EXPECTED_KUBECONFIG}.next" ]]; then + exit 55 +fi +exec /usr/bin/chmod "$@" +FAKE_CHMOD + +cat >"${FAKE_BIN}/openssl" <<'FAKE_OPENSSL' +#!/usr/bin/env bash +set -Eeuo pipefail +printf 'openssl' >>"${REDIS_LAB_FAKE_LOG}" +printf ' <%s>' "$@" >>"${REDIS_LAB_FAKE_LOG}" +printf '\n' >>"${REDIS_LAB_FAKE_LOG}" +[[ "$*" == 'rand -hex 32' ]] || exit 60 +printf '%s\n' '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +FAKE_OPENSSL + +cat >"${FAKE_BIN}/scenario-success" <<'FAKE_SUCCESS' +#!/usr/bin/env bash +if [[ -n "${REDIS_LAB_FAKE_SCENARIO_MARKER:-}" ]]; then + : >"${REDIS_LAB_FAKE_SCENARIO_MARKER}" +fi +exit 0 +FAKE_SUCCESS + +cat >"${FAKE_BIN}/scenario-failure" <<'FAKE_FAILURE' +#!/usr/bin/env bash +exit 23 +FAKE_FAILURE + +cat >"${FAKE_BIN}/scenario-background-child" <<'FAKE_BACKGROUND' +#!/usr/bin/env bash +set -Eeuo pipefail +/bin/sleep 3 & +printf '%s\n' "$!" >"${REDIS_LAB_FAKE_BACKGROUND_PID_FILE}" +exit 0 +FAKE_BACKGROUND + +find "${FAKE_BIN}" -maxdepth 1 -type f -exec chmod 0700 -- {} + +[[ -x "${LAB_SCRIPT}" ]] || fail "lifecycle script is missing or not executable" +assert_fd9_static_contract "${LAB_SCRIPT}" + +SEALED_STRENGTH_VIOLATIONS="${FIXTURE_ROOT}/sealed-strength.violations" +SEALED_STRENGTH_SCRIPT="${FIXTURE_ROOT}/unsafe-fixture-lifecycle" +: >"${SEALED_STRENGTH_VIOLATIONS}" +cat >"${SEALED_STRENGTH_SCRIPT}" <<'SEALED_STRENGTH' +#!/usr/bin/env bash +set -Eeuo pipefail +docker version +SEALED_STRENGTH +chmod 0700 -- "${SEALED_STRENGTH_SCRIPT}" +assert_fails env \ + PATH="${FAKE_BIN}" \ + REDIS_LAB_FAKE_VIOLATIONS="${SEALED_STRENGTH_VIOLATIONS}" \ + "${SEALED_STRENGTH_SCRIPT}" +assert_file_contains "${SEALED_STRENGTH_VIOLATIONS}" 'sealed-path-denied-command' + +BAD_VERSION_REPOSITORY="${FIXTURE_ROOT}/bad-version-repository" +prepare_tracked_input_repository "${BAD_VERSION_REPOSITORY}" +printf '%s\n' 'K3S_VERSION=not-pinned' 'MULTIPASS_IMAGE=24.04' \ + >"${BAD_VERSION_REPOSITORY}/infra/redis-lab/versions.env" +reset_case +assert_fails run_lab "${BAD_VERSION_REPOSITORY}/infra/redis-lab/bin/redis-lab" preflight +assert_count 0 '^(multipass|kubectl|ip|sha256sum|openssl|timeout) ' "${FAKE_LOG}" + +SYMLINK_CLOUD_INIT_REPOSITORY="${FIXTURE_ROOT}/symlink-cloud-init-repository" +SYMLINK_CLOUD_INIT_ESCAPE="${FIXTURE_ROOT}/symlink-cloud-init-escape" +prepare_tracked_input_repository "${SYMLINK_CLOUD_INIT_REPOSITORY}" +rm -f -- "${SYMLINK_CLOUD_INIT_REPOSITORY}/infra/redis-lab/cloud-init/node.yaml" +printf '%s\n' \ + '#cloud-config' \ + 'write_files:' \ + 'runcmd:' \ + ' - [ sh, -c, "printf injected-bootstrap > /var/lib/redis-lab-canary" ]' \ + >"${SYMLINK_CLOUD_INIT_ESCAPE}" +ln -s -- "${SYMLINK_CLOUD_INIT_ESCAPE}" \ + "${SYMLINK_CLOUD_INIT_REPOSITORY}/infra/redis-lab/cloud-init/node.yaml" +reset_case +CLOUD_INIT_SYMLINK_ACCEPTED=0 +SYMLINK_CLOUD_INIT_RUNTIME_ROOT="${SYMLINK_CLOUD_INIT_REPOSITORY}/src/build/redis-lab" +if RUN_LAB_EXPECTED_STATE="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/run.state" \ + RUN_LAB_EXPECTED_KUBECONFIG="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/kubeconfig" \ + RUN_LAB_EXPECTED_HOST_KUBECONFIG="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/observations/host-kubeconfig" \ + RUN_LAB_EXPECTED_CLOUD_INIT_ROOT="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/cloud-init" \ + run_lab \ + "${SYMLINK_CLOUD_INIT_REPOSITORY}/infra/redis-lab/bin/redis-lab" up; then + CLOUD_INIT_SYMLINK_ACCEPTED=1 + RUN_LAB_EXPECTED_STATE="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/run.state" \ + RUN_LAB_EXPECTED_KUBECONFIG="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/kubeconfig" \ + RUN_LAB_EXPECTED_HOST_KUBECONFIG="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/observations/host-kubeconfig" \ + RUN_LAB_EXPECTED_CLOUD_INIT_ROOT="${SYMLINK_CLOUD_INIT_RUNTIME_ROOT}/cloud-init" \ + run_lab \ + "${SYMLINK_CLOUD_INIT_REPOSITORY}/infra/redis-lab/bin/redis-lab" down +fi +CLOUD_INIT_PREVALIDATION_CHILD_COUNT="$( + grep -Ec \ + '^(child|multipass|kubectl|ip|sha256sum|openssl|timeout|curl|uname) ' \ + "${FAKE_LOG}" || true +)" + +SYMLINK_VERSION_REPOSITORY="${FIXTURE_ROOT}/symlink-version-repository" +SYMLINK_VERSION_ESCAPE="${FIXTURE_ROOT}/symlink-version-escape" +SYMLINK_VERSION_CANARY="${FIXTURE_ROOT}/symlink-version-source.executed" +prepare_tracked_input_repository "${SYMLINK_VERSION_REPOSITORY}" +rm -f -- \ + "${SYMLINK_VERSION_REPOSITORY}/infra/redis-lab/versions.env" \ + "${SYMLINK_VERSION_CANARY}" +printf '%s\n' \ + 'K3S_VERSION=v1.33.3+k3s1' \ + 'MULTIPASS_IMAGE=24.04' \ + 'K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' \ + 'K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' \ + "printf '%s\\n' 'versions-source-executed' >'${SYMLINK_VERSION_CANARY}'" \ + 'return 97' \ + >"${SYMLINK_VERSION_ESCAPE}" +ln -s -- "${SYMLINK_VERSION_ESCAPE}" \ + "${SYMLINK_VERSION_REPOSITORY}/infra/redis-lab/versions.env" +reset_case +assert_fails run_lab \ + "${SYMLINK_VERSION_REPOSITORY}/infra/redis-lab/bin/redis-lab" preflight +VERSION_SYMLINK_EXECUTED=0 +[[ ! -e "${SYMLINK_VERSION_CANARY}" ]] || VERSION_SYMLINK_EXECUTED=1 +VERSION_PREVALIDATION_CHILD_COUNT="$( + grep -Ec \ + '^(child|multipass|kubectl|ip|sha256sum|openssl|timeout|curl|uname) ' \ + "${FAKE_LOG}" || true +)" +if ((CLOUD_INIT_SYMLINK_ACCEPTED == 1)); then + printf '%s\n' \ + 'redis-lab-contract: symlinked cloud-init was accepted for VM bootstrap' >&2 +fi +if ((VERSION_SYMLINK_EXECUTED == 1)); then + printf '%s\n' \ + 'redis-lab-contract: symlinked versions file executed host shell' >&2 +fi +if ((CLOUD_INIT_SYMLINK_ACCEPTED == 1 || VERSION_SYMLINK_EXECUTED == 1)) || + [[ "${CLOUD_INIT_PREVALIDATION_CHILD_COUNT}" != 0 || + "${VERSION_PREVALIDATION_CHILD_COUNT}" != 0 ]]; then + fail 'tracked input symlink validation was not fail-closed' +fi + +INVALID_VERSIONS_REPOSITORY="${FIXTURE_ROOT}/invalid-versions-repository" +for versions_variant in unknown duplicate missing malformed; do + prepare_tracked_input_repository "${INVALID_VERSIONS_REPOSITORY}" + case "${versions_variant}" in + unknown) + printf '%s\n' \ + 'K3S_VERSION=v1.33.3+k3s1' \ + 'MULTIPASS_IMAGE=24.04' \ + 'K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' \ + 'K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' \ + 'REDIS_LAB_UNKNOWN=value' \ + >"${INVALID_VERSIONS_REPOSITORY}/infra/redis-lab/versions.env" + ;; + duplicate) + printf '%s\n' \ + 'K3S_VERSION=v1.33.3+k3s1' \ + 'K3S_VERSION=v1.33.3+k3s1' \ + 'MULTIPASS_IMAGE=24.04' \ + 'K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' \ + 'K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' \ + >"${INVALID_VERSIONS_REPOSITORY}/infra/redis-lab/versions.env" + ;; + missing) + printf '%s\n' \ + 'K3S_VERSION=v1.33.3+k3s1' \ + 'MULTIPASS_IMAGE=24.04' \ + 'K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' \ + >"${INVALID_VERSIONS_REPOSITORY}/infra/redis-lab/versions.env" + ;; + malformed) + printf '%s\n' \ + 'K3S_VERSION =v1.33.3+k3s1' \ + 'MULTIPASS_IMAGE=24.04' \ + 'K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s' \ + 'K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc' \ + >"${INVALID_VERSIONS_REPOSITORY}/infra/redis-lab/versions.env" + ;; + esac + reset_case + assert_fails run_lab \ + "${INVALID_VERSIONS_REPOSITORY}/infra/redis-lab/bin/redis-lab" preflight + assert_count 0 \ + '^(multipass|kubectl|ip|sha256sum|openssl|timeout|curl|uname) ' \ + "${FAKE_LOG}" + [[ ! -e "${INVALID_VERSIONS_REPOSITORY}/src/build/redis-lab" ]] || + fail "${versions_variant} versions input published runtime state" +done + +MISSING_RENDERER_REPOSITORY="${FIXTURE_ROOT}/missing-renderer-repository" +rm -rf -- "${MISSING_RENDERER_REPOSITORY}" +mkdir -p -- \ + "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/bin" \ + "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/cloud-init" \ + "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/lib" \ + "${MISSING_RENDERER_REPOSITORY}/src" +cp -- "${LAB_SCRIPT}" "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/bin/redis-lab" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" \ + "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/cloud-init/node.yaml" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/versions.env" \ + "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/versions.env" +chmod 0700 -- "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/bin/redis-lab" +reset_case +assert_fails run_lab \ + "${MISSING_RENDERER_REPOSITORY}/infra/redis-lab/bin/redis-lab" preflight +assert_count 0 \ + '^(multipass|kubectl|ip|sha256sum|openssl|timeout|curl|uname) ' \ + "${FAKE_LOG}" +[[ ! -e "${MISSING_RENDERER_REPOSITORY}/src/build/redis-lab" ]] || + fail 'missing renderer published runtime state' + +SYMLINK_RENDERER_REPOSITORY="${FIXTURE_ROOT}/symlink-renderer-repository" +SYMLINK_RENDERER_ESCAPE="${FIXTURE_ROOT}/symlink-renderer-escape" +rm -rf -- "${SYMLINK_RENDERER_REPOSITORY}" +mkdir -p -- \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/bin" \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/cloud-init" \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/lib" \ + "${SYMLINK_RENDERER_REPOSITORY}/src" +cp -- "${LAB_SCRIPT}" "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/bin/redis-lab" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/cloud-init/node.yaml" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/versions.env" \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/versions.env" +printf '%s\n' 'renderer-canary' >"${SYMLINK_RENDERER_ESCAPE}" +ln -s -- "${SYMLINK_RENDERER_ESCAPE}" \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/lib/render-kubeconfig.awk" +chmod 0700 -- "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/bin/redis-lab" +reset_case +assert_fails run_lab \ + "${SYMLINK_RENDERER_REPOSITORY}/infra/redis-lab/bin/redis-lab" preflight +assert_count 0 \ + '^(multipass|kubectl|ip|sha256sum|openssl|timeout|curl|uname) ' \ + "${FAKE_LOG}" +assert_file_contains "${SYMLINK_RENDERER_ESCAPE}" 'renderer-canary' +[[ ! -e "${SYMLINK_RENDERER_REPOSITORY}/src/build/redis-lab" ]] || + fail 'symlink renderer published runtime state' + +SYMLINK_REPOSITORY="${FIXTURE_ROOT}/symlink-repository" +SYMLINK_ESCAPE="${FIXTURE_ROOT}/symlink-escape" +rm -rf -- "${SYMLINK_REPOSITORY}" "${SYMLINK_ESCAPE}" +mkdir -p -- \ + "${SYMLINK_REPOSITORY}/infra/redis-lab/bin" \ + "${SYMLINK_REPOSITORY}/infra/redis-lab/cloud-init" \ + "${SYMLINK_REPOSITORY}/infra/redis-lab/lib" \ + "${SYMLINK_REPOSITORY}/src" \ + "${SYMLINK_ESCAPE}" +cp -- "${LAB_SCRIPT}" "${SYMLINK_REPOSITORY}/infra/redis-lab/bin/redis-lab" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/cloud-init/node.yaml" \ + "${SYMLINK_REPOSITORY}/infra/redis-lab/cloud-init/node.yaml" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/lib/render-kubeconfig.awk" \ + "${SYMLINK_REPOSITORY}/infra/redis-lab/lib/render-kubeconfig.awk" +cp -- "${SOURCE_REPOSITORY_ROOT}/infra/redis-lab/versions.env" \ + "${SYMLINK_REPOSITORY}/infra/redis-lab/versions.env" +ln -s -- "${SYMLINK_ESCAPE}" "${SYMLINK_REPOSITORY}/src/build" +chmod 0700 -- "${SYMLINK_REPOSITORY}/infra/redis-lab/bin/redis-lab" +reset_case +assert_fails run_lab "${SYMLINK_REPOSITORY}/infra/redis-lab/bin/redis-lab" preflight +assert_count 0 '^(multipass|kubectl|ip|sha256sum|openssl|timeout) ' "${FAKE_LOG}" +[[ -z "$(find "${SYMLINK_ESCAPE}" -mindepth 1 -print -quit)" ]] || + fail 'runtime path validation wrote through an ancestor symlink' + +reset_case +CHILD_ESCAPE="${FIXTURE_ROOT}/child-symlink-escape" +printf '%s\n' 'child-canary' >"${CHILD_ESCAPE}" +mkdir -p -- "${RUNTIME_ROOT}" +ln -s -- "${CHILD_ESCAPE}" "${EXPECTED_STATE}" +assert_fails run_lab "${LAB_SCRIPT}" preflight +assert_count 0 '^(multipass|kubectl|ip|sha256sum|openssl|timeout) ' "${FAKE_LOG}" +assert_file_contains "${CHILD_ESCAPE}" 'child-canary' + +reset_case +KUBECONFIG_NEXT_ESCAPE="${FIXTURE_ROOT}/kubeconfig-next-child-escape" +printf '%s\n' 'kubeconfig-next-canary' >"${KUBECONFIG_NEXT_ESCAPE}" +mkdir -p -- "${RUNTIME_ROOT}" +ln -s -- "${KUBECONFIG_NEXT_ESCAPE}" "${EXPECTED_KUBECONFIG}.next" +assert_fails run_lab "${LAB_SCRIPT}" preflight +assert_count 0 \ + '^(multipass|kubectl|ip|sha256sum|openssl|timeout|curl|uname) ' \ + "${FAKE_LOG}" +assert_file_contains "${KUBECONFIG_NEXT_ESCAPE}" 'kubeconfig-next-canary' +[[ ! -e "${EXPECTED_KUBECONFIG}" ]] || + fail 'kubeconfig next symlink published a destination' +rm -f -- "${EXPECTED_KUBECONFIG}.next" + +reset_case +run_lab "${LAB_SCRIPT}" preflight +OBSERVATION_ESCAPE="${FIXTURE_ROOT}/observation-child-escape" +printf '%s\n' 'observation-canary' >"${OBSERVATION_ESCAPE}" +ln -s -- "${OBSERVATION_ESCAPE}" \ + "${RUNTIME_ROOT}/observations/fingerprint-after.raw" +assert_fails run_lab "${LAB_SCRIPT}" postflight +assert_file_contains "${OBSERVATION_ESCAPE}" 'observation-canary' +rm -f -- "${RUNTIME_ROOT}/observations/fingerprint-after.raw" +run_lab "${LAB_SCRIPT}" down + +reset_case +run_lab "${LAB_SCRIPT}" preflight +RENDERED_CLOUD_INIT_ESCAPE="${FIXTURE_ROOT}/rendered-cloud-init-escape" +printf '%s\n' 'rendered-cloud-init-canary' >"${RENDERED_CLOUD_INIT_ESCAPE}" +mkdir -p -- "${EXPECTED_CLOUD_INIT_ROOT}" +ln -s -- "${RENDERED_CLOUD_INIT_ESCAPE}" \ + "${EXPECTED_CLOUD_INIT_ROOT}/ca-redis-lab-server.yaml" +: >"${FAKE_LOG}" +assert_fails run_lab "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_file_contains "${RENDERED_CLOUD_INIT_ESCAPE}" 'rendered-cloud-init-canary' +rm -f -- "${EXPECTED_CLOUD_INIT_ROOT}/ca-redis-lab-server.yaml" +run_lab "${LAB_SCRIPT}" down + +reset_case +CONCURRENT_BARRIER="${FIXTURE_ROOT}/concurrent-barrier" +rm -f -- "${CONCURRENT_BARRIER}.started" "${CONCURRENT_BARRIER}.release" +run_lab env REDIS_LAB_FAKE_LAUNCH_BARRIER="${CONCURRENT_BARRIER}" \ + "${LAB_SCRIPT}" up >"${FIXTURE_ROOT}/concurrent-first.out" 2>&1 & +CONCURRENT_PID=$! +for barrier_attempt in {1..100}; do + [[ -f "${CONCURRENT_BARRIER}.started" ]] && break + /bin/sleep 0.02 +done +[[ -f "${CONCURRENT_BARRIER}.started" ]] || + fail 'concurrent launch barrier was not reached' +cp -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/concurrent-state.before" +if run_lab "${LAB_SCRIPT}" preflight; then + : >"${CONCURRENT_BARRIER}.release" + wait "${CONCURRENT_PID}" || true + fail 'concurrent lifecycle invocation acquired an active run' +fi +cmp -s -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/concurrent-state.before" || + fail 'concurrent lifecycle invocation changed active run state' +: >"${CONCURRENT_BARRIER}.release" +wait "${CONCURRENT_PID}" || fail 'first concurrent lifecycle did not finish' +run_lab "${LAB_SCRIPT}" down + +reset_case +DIRECT_TOOL_BACKGROUND_PID_FILE="${FIXTURE_ROOT}/direct-tool-background-child.pid" +rm -f -- "${DIRECT_TOOL_BACKGROUND_PID_FILE}" +run_lab env \ + REDIS_LAB_FAKE_DIRECT_TOOL_BACKGROUND_PID_FILE="${DIRECT_TOOL_BACKGROUND_PID_FILE}" \ + "${LAB_SCRIPT}" preflight +[[ -s "${DIRECT_TOOL_BACKGROUND_PID_FILE}" ]] || + fail 'direct-tool background child pid was not recorded' +DIRECT_TOOL_BACKGROUND_PID="$(cat "${DIRECT_TOOL_BACKGROUND_PID_FILE}")" +if ! run_lab "${LAB_SCRIPT}" preflight; then + kill "${DIRECT_TOOL_BACKGROUND_PID}" 2>/dev/null || true + fail 'detached direct-tool child inherited the lifecycle lock' +fi +kill "${DIRECT_TOOL_BACKGROUND_PID}" 2>/dev/null || true +run_lab "${LAB_SCRIPT}" down + +reset_case +INFRA_BACKGROUND_PID_FILE="${FIXTURE_ROOT}/infra-background-child.pid" +rm -f -- "${INFRA_BACKGROUND_PID_FILE}" +run_lab env REDIS_LAB_FAKE_INFRA_BACKGROUND_PID_FILE="${INFRA_BACKGROUND_PID_FILE}" \ + "${LAB_SCRIPT}" preflight +[[ -s "${INFRA_BACKGROUND_PID_FILE}" ]] || + fail 'infra background child pid was not recorded' +INFRA_BACKGROUND_PID="$(cat "${INFRA_BACKGROUND_PID_FILE}")" +if ! run_lab "${LAB_SCRIPT}" preflight; then + kill "${INFRA_BACKGROUND_PID}" 2>/dev/null || true + fail 'detached infra child inherited the lifecycle lock' +fi +kill "${INFRA_BACKGROUND_PID}" 2>/dev/null || true +run_lab "${LAB_SCRIPT}" down + +reset_case +SIGNAL_BARRIER="${FIXTURE_ROOT}/signal-barrier" +rm -f -- "${SIGNAL_BARRIER}.started" "${SIGNAL_BARRIER}.release" +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env \ + REDIS_LAB_FAKE_LAUNCH_SIGNAL_PARENT=1 \ + REDIS_LAB_FAKE_LAUNCH_BARRIER="${SIGNAL_BARRIER}" \ + "${LAB_SCRIPT}" up +[[ -f "${SIGNAL_BARRIER}.started" ]] || fail 'signal launch barrier was not reached' +assert_count 1 '^multipass <--purge> $' "${FAKE_LOG}" +assert_count 1 '^unrelated-instance$' "${FAKE_INVENTORY}" + +reset_case +RUN_SIGNAL_BARRIER="${FIXTURE_ROOT}/run-signal-barrier" +rm -f -- "${RUN_SIGNAL_BARRIER}.started" "${RUN_SIGNAL_BARRIER}.release" +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env \ + REDIS_LAB_FAKE_LAUNCH_SIGNAL_PARENT=1 \ + REDIS_LAB_FAKE_LAUNCH_BARRIER="${RUN_SIGNAL_BARRIER}" \ + "${LAB_SCRIPT}" run -- scenario-success +[[ -f "${RUN_SIGNAL_BARRIER}.started" ]] || + fail 'run-flow launch barrier was not reached' +assert_count 1 '^multipass <--purge> $' "${FAKE_LOG}" +assert_count 1 '^unrelated-instance$' "${FAKE_INVENTORY}" + +reset_case +RUN_HANDOFF_SCENARIO_MARKER="${FIXTURE_ROOT}/run-handoff-scenario.started" +rm -f -- "${RUN_HANDOFF_SCENARIO_MARKER}" +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env \ + REDIS_LAB_FAKE_RUN_HANDOFF_SIGNAL=1 \ + REDIS_LAB_FAKE_SCENARIO_MARKER="${RUN_HANDOFF_SCENARIO_MARKER}" \ + "${LAB_SCRIPT}" run -- scenario-success +[[ -f "${EXPECTED_RUN_HANDOFF_MARKER}" ]] || + fail 'run-flow post-up handoff seam was not reached' +[[ ! -e "${RUN_HANDOFF_SCENARIO_MARKER}" ]] || + fail 'run-flow executed the user command after handoff TERM' +assert_count 3 '^multipass <--purge> "${FAKE_INVENTORY}" +printf '%s\n' \ + 'ca-redis-lab-server|run-701-702-703|ca-redis-lab-server' >"${FAKE_OWNERSHIP}" +printf '%s\n' \ + 'RUN|run-701-702-703' \ + 'CREATED|run-701-702-703|ca-redis-lab-server' >"${EXPECTED_STATE}" +cp -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/prior-created-state.before" +assert_fails run_lab "${LAB_SCRIPT}" run -- scenario-success +assert_count 0 '^multipass ' "${FAKE_LOG}" +cmp -s -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/prior-created-state.before" || + fail 'rejected run changed a prior CREATED state' +assert_count 1 '^ca-redis-lab-server$' "${FAKE_INVENTORY}" +assert_count 1 '^unrelated-instance$' "${FAKE_INVENTORY}" + +reset_case +printf '%s\n' \ + 'ca-redis-lab-server' \ + 'unrelated-instance' >"${FAKE_INVENTORY}" +printf '%s\n' \ + 'ca-redis-lab-server|run-704-705-706|ca-redis-lab-server' >"${FAKE_OWNERSHIP}" +printf '%s\n' \ + 'RUN|run-704-705-706' \ + 'RECONCILE|run-704-705-706|ca-redis-lab-server' >"${EXPECTED_STATE}" +cp -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/prior-reconcile-state.before" +assert_fails run_lab "${LAB_SCRIPT}" run -- scenario-success +assert_count 0 '^multipass ' "${FAKE_LOG}" +cmp -s -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/prior-reconcile-state.before" || + fail 'rejected run changed a prior RECONCILE state' +assert_count 1 '^ca-redis-lab-server$' "${FAKE_INVENTORY}" +assert_count 1 '^unrelated-instance$' "${FAKE_INVENTORY}" + +reset_case +assert_fails run_lab env REDIS_LAB_FAKE_LAUNCH_MARKER_MODE=missing "${LAB_SCRIPT}" up +assert_count 1 '^multipass .*' "${FAKE_LOG}" +assert_count 0 '^multipass .*' "${FAKE_LOG}" +assert_count 1 '^RECONCILE\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-server$' \ + "${EXPECTED_STATE}" +assert_file_contains "${FAKE_INVENTORY}" 'ca-redis-lab-server' + +reset_case +assert_fails run_lab env REDIS_LAB_FAKE_LAUNCH_MARKER_MODE=foreign "${LAB_SCRIPT}" up +assert_count 1 '^multipass .*' "${FAKE_LOG}" +assert_count 0 '^multipass .*' "${FAKE_LOG}" +assert_count 1 '^RECONCILE\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-server$' \ + "${EXPECTED_STATE}" +assert_file_contains "${FAKE_INVENTORY}" 'ca-redis-lab-server' + +reset_case +run_lab "${LAB_SCRIPT}" up +assert_count 3 '^multipass ' "${FAKE_LOG}" +assert_file_contains "${FAKE_LOG}" \ + "multipass <--name> <--cpus> <2> <--memory> <3G> <--disk> <12G> <--cloud-init> <${EXPECTED_CLOUD_INIT_ROOT}/ca-redis-lab-server.yaml> <24.04>" +assert_file_contains "${FAKE_LOG}" \ + "multipass <--name> <--cpus> <2> <--memory> <2560M> <--disk> <12G> <--cloud-init> <${EXPECTED_CLOUD_INIT_ROOT}/ca-redis-lab-agent-1.yaml> <24.04>" +assert_file_contains "${FAKE_LOG}" \ + "multipass <--name> <--cpus> <2> <--memory> <2560M> <--disk> <12G> <--cloud-init> <${EXPECTED_CLOUD_INIT_ROOT}/ca-redis-lab-agent-2.yaml> <24.04>" +assert_file_contains "${FAKE_LOG}" \ + 'curl <--fail> <--location> <--silent> <--show-error> <--output> <'"${RUNTIME_ROOT}"'/k3s-amd64> ' +assert_count 3 '^multipass ' "${FAKE_LOG}" +assert_count 1 '^multipass .* <--cluster-cidr=10\.52\.0\.0/16> <--service-cidr=10\.53\.0\.0/16> <--disable=traefik> <--disable=servicelb> <--write-kubeconfig-mode=0600>$' \ + "${FAKE_LOG}" +assert_count 2 '^multipass .* $' \ + "${FAKE_LOG}" +assert_count 1 "^kubectl <--kubeconfig> <${EXPECTED_KUBECONFIG}> <--context> " \ + "${FAKE_LOG}" +assert_count 5 "^kubectl <--kubeconfig> <${EXPECTED_HOST_KUBECONFIG}> <--context> " \ + "${FAKE_LOG}" +cmp -s -- "${TEST_HOME_ROOT}/.kube/config" "${DEFAULT_KUBECONFIG_SNAPSHOT}" || + fail 'default kubeconfig was mutated' +[[ ! -e "${EXPECTED_HOST_KUBECONFIG}" ]] || + fail 'run-scoped host kubeconfig was retained' +assert_file_contains "${RUNTIME_ROOT}/fingerprint.before" 'lab-resource-count|0' +assert_count 3 '^CREATED\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-' "${EXPECTED_STATE}" +SUCCESS_RUN_ID="$(awk -F'|' '$1 == "RUN" {print $2}' "${EXPECTED_STATE}")" +for expected_name in \ + ca-redis-lab-server \ + ca-redis-lab-agent-1 \ + ca-redis-lab-agent-2; do + [[ "$(/usr/bin/stat -c '%a' "${EXPECTED_CLOUD_INIT_ROOT}/${expected_name}.yaml")" == 600 ]] || + fail 'rendered cloud-init permissions are not 0600' + assert_file_contains "${EXPECTED_CLOUD_INIT_ROOT}/${expected_name}.yaml" \ + " ${SUCCESS_RUN_ID}|${expected_name}" + if grep -Fq '0123456789abcdef0123456789abcdef' \ + "${EXPECTED_CLOUD_INIT_ROOT}/${expected_name}.yaml"; then + fail 'rendered ownership marker contains secret material' + fi +done +assert_file_contains "${EXPECTED_KUBECONFIG}" ' server: https://192.0.2.10:6443' +assert_count 2 '^ name: ca-redis-lab$' "${EXPECTED_KUBECONFIG}" +assert_file_contains "${EXPECTED_KUBECONFIG}" ' cluster: ca-redis-lab' +assert_file_contains "${EXPECTED_KUBECONFIG}" ' user: ca-redis-lab' +assert_file_contains "${EXPECTED_KUBECONFIG}" 'current-context: ca-redis-lab' +assert_file_contains "${EXPECTED_KUBECONFIG}" '- name: ca-redis-lab' +assert_file_contains "${EXPECTED_KUBECONFIG}" ' namespace: team-default' +assert_file_contains "${EXPECTED_KUBECONFIG}" \ + ' certificate-authority-data: preserve-default-ca-canary' +assert_file_contains "${EXPECTED_KUBECONFIG}" \ + ' client-certificate-data: preserve-default-client-cert-canary' +assert_file_contains "${EXPECTED_KUBECONFIG}" \ + ' client-key-data: preserve-default-client-key-canary' +cmp -s -- "${EXPECTED_KUBECONFIG}" "${KUBECONFIG_WITH_NAMESPACE_GOLDEN}" || + fail 'namespace-present kubeconfig output changed' +[[ "$(/usr/bin/stat -c '%a' "${EXPECTED_KUBECONFIG}")" == 600 ]] || + fail 'lab kubeconfig permissions are not 0600' +[[ ! -s "${FAKE_VIOLATIONS}" ]] || fail 'exact launch/state contract violation' +run_lab "${LAB_SCRIPT}" down +assert_count 3 '^multipass <--purge> <--> $' \ + "${FAKE_LOG}" +[[ ! -s "${FAKE_INVENTORY}" ]] || fail 'down left lab instances' + +assert_created_cleanup_reconciles() { + local mode="$1" + local created_run_id + local expected_server_delete_count=0 + + reset_case + run_lab "${LAB_SCRIPT}" up + created_run_id="$(awk -F'|' '$1 == "RUN" {print $2}' "${EXPECTED_STATE}")" + case "${mode}" in + missing) + printf '%s\n' \ + "ca-redis-lab-agent-1|${created_run_id}|ca-redis-lab-agent-1" \ + "ca-redis-lab-agent-2|${created_run_id}|ca-redis-lab-agent-2" \ + >"${FAKE_OWNERSHIP}" + ;; + foreign) + printf '%s\n' \ + 'ca-redis-lab-server|run-999-999-999|ca-redis-lab-server' \ + "ca-redis-lab-agent-1|${created_run_id}|ca-redis-lab-agent-1" \ + "ca-redis-lab-agent-2|${created_run_id}|ca-redis-lab-agent-2" \ + >"${FAKE_OWNERSHIP}" + ;; + delete-failure) + expected_server_delete_count=1 + ;; + *) + fail 'invalid CREATED cleanup test mode' + ;; + esac + : >"${FAKE_LOG}" + if [[ "${mode}" == delete-failure ]]; then + assert_fails run_lab env \ + REDIS_LAB_FAKE_DELETE_FAILURE_NAME=ca-redis-lab-server \ + "${LAB_SCRIPT}" down + else + assert_fails run_lab "${LAB_SCRIPT}" down + fi + assert_count "${expected_server_delete_count}" \ + '^multipass <--purge> $' "${FAKE_LOG}" + assert_count 2 '^multipass <--purge> "${FAKE_OWNERSHIP}" + run_lab "${LAB_SCRIPT}" down +} + +assert_created_cleanup_reconciles missing +assert_created_cleanup_reconciles foreign +assert_created_cleanup_reconciles delete-failure + +reset_case +printf '%s\n' 'ca-redis-lab-agent-1' >"${FAKE_INVENTORY}" +assert_fails run_lab "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_file_contains "${FAKE_INVENTORY}" 'ca-redis-lab-agent-1' + +reset_case +assert_fails env REDIS_LAB_FAIL_LAUNCH_NAME='ca-redis-lab-agent-2' \ + PATH="${FAKE_BIN}" \ + REDIS_LAB_CONTRACT_TEST=1 \ + REDIS_LAB_HOST_SERVICE_CIDRS='10.81.0.0/16' \ + REDIS_LAB_HOME_DIR="${TEST_HOME_ROOT}" \ + REDIS_LAB_FAKE_LOG="${FAKE_LOG}" \ + REDIS_LAB_FAKE_INVENTORY="${FAKE_INVENTORY}" \ + REDIS_LAB_FAKE_VIOLATIONS="${FAKE_VIOLATIONS}" \ + REDIS_LAB_EXPECTED_STATE="${EXPECTED_STATE}" \ + REDIS_LAB_EXPECTED_KUBECONFIG="${EXPECTED_KUBECONFIG}" \ + REDIS_LAB_EXPECTED_HOST_KUBECONFIG="${EXPECTED_HOST_KUBECONFIG}" \ + "${LAB_SCRIPT}" up +assert_count 3 '^multipass ' "${FAKE_LOG}" +assert_file_contains "${FAKE_LOG}" \ + 'multipass <--purge> ' +assert_file_contains "${FAKE_LOG}" \ + 'multipass <--purge> ' +assert_count 0 '^multipass <--purge> $' "${FAKE_LOG}" +assert_count 1 '^RECONCILE\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-agent-2$' \ + "${EXPECTED_STATE}" +[[ ! -s "${FAKE_INVENTORY}" ]] || fail 'partial failure cleanup was incomplete' +[[ ! -s "${FAKE_VIOLATIONS}" ]] || fail 'immediate state recording failed' + +reset_case +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env REDIS_LAB_FAIL_STATE_RESERVATION=1 "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_file_contains "${FAKE_INVENTORY}" 'unrelated-instance' + +reset_case +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env \ + REDIS_LAB_FAKE_TIMEOUT_COMMAND=multipass-launch \ + REDIS_LAB_FAKE_LATE_CREATE_MODE=matching \ + "${LAB_SCRIPT}" up +assert_count 1 '^timeout .* .*' "${FAKE_LOG}" +assert_count 1 '^multipass <--purge> $' "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_file_contains "${FAKE_INVENTORY}" 'unrelated-instance' +[[ ! -e "${EXPECTED_STATE}" ]] || fail 'matching late create retained run state' + +reset_case +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env \ + REDIS_LAB_FAKE_TIMEOUT_COMMAND=multipass-launch \ + REDIS_LAB_FAKE_LATE_CREATE_MODE=absent \ + "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 1 '^RECONCILE\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-server$' \ + "${EXPECTED_STATE}" +cp -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/absent-reconcile-state.before" +: >"${FAKE_LOG}" +assert_fails run_lab "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +cmp -s -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/absent-reconcile-state.before" || + fail 'new up overwrote absent reconciliation state' + +reset_case +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env \ + REDIS_LAB_FAKE_TIMEOUT_COMMAND=multipass-launch \ + REDIS_LAB_FAKE_LATE_CREATE_MODE=foreign \ + "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 1 '^RECONCILE\|run-[0-9]+-[0-9]+-[0-9]+\|ca-redis-lab-server$' \ + "${EXPECTED_STATE}" +assert_file_contains "${FAKE_INVENTORY}" 'ca-redis-lab-server' +cp -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/foreign-reconcile-state.before" +: >"${FAKE_LOG}" +assert_fails run_lab "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +cmp -s -- "${EXPECTED_STATE}" "${FIXTURE_ROOT}/foreign-reconcile-state.before" || + fail 'new up overwrote foreign reconciliation state' + +reset_case +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +assert_fails run_lab env REDIS_LAB_FAIL_STATE_PROMOTION=1 "${LAB_SCRIPT}" up +assert_count 1 '^multipass .*' "${FAKE_LOG}" +assert_count 1 '^multipass <--purge> $' "${FAKE_LOG}" +assert_count 0 '^multipass .*"${FAKE_INVENTORY}" +assert_fails run_lab env REDIS_LAB_FAIL_STATE_CHMOD=1 "${LAB_SCRIPT}" up +assert_count 1 '^multipass .*' "${FAKE_LOG}" +assert_count 1 '^multipass <--purge> $' "${FAKE_LOG}" +assert_count 0 '^multipass .*$' \ + "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 3 '^multipass <--purge> ' "${FAKE_LOG}" +assert_count 0 '^vm-install ' "${FAKE_LOG}" +assert_count 3 '^multipass <--purge> <${EXPECTED_KUBECONFIG}> <--context> " \ + "${FAKE_LOG}" + for expected_name in \ + ca-redis-lab-server \ + ca-redis-lab-agent-1 \ + ca-redis-lab-agent-2; do + assert_count 2 \ + "^multipass <${expected_name}> <--> $" \ + "${FAKE_LOG}" + assert_count 1 \ + "^multipass <--purge> <${expected_name}>$" \ + "${FAKE_LOG}" + done + [[ ! -e "${EXPECTED_KUBECONFIG}" ]] || + fail "${label} retained the kubeconfig destination" + [[ ! -e "${EXPECTED_KUBECONFIG}.next" ]] || + fail "${label} retained the kubeconfig next file" + [[ ! -e "${EXPECTED_STATE}" ]] || + fail "${label} retained current-run state after exact cleanup" + [[ ! -s "${FAKE_INVENTORY}" ]] || + fail "${label} left a current-run instance" + assert_file_not_contains_pattern "${FAKE_LOG}" '^multipass ' + assert_file_not_contains_pattern "${FAKE_LOG}" '^multipass .*<--all>' + assert_file_not_contains_pattern "${FAKE_LOG}" '^multipass .*<[?*]>' + [[ ! -s "${FAKE_VIOLATIONS}" ]] || + fail "${label} used a forbidden fake invocation" +} + +KUBECONFIG_MUTATIONS=( + missing-server + duplicate-server + reordered-kind-preferences + unknown-cluster-key + whitespace-before-colon + quoted-key + tagged-key + explicit-key + anchor + alias + merge + flow-map + flow-sequence + tab-indentation + crlf + document-start + document-end + trailing-content + sibling-flow-cluster + sibling-flow-context +) +for kubeconfig_mutation in "${KUBECONFIG_MUTATIONS[@]}"; do + assert_kubeconfig_render_failure \ + "kubeconfig mutation ${kubeconfig_mutation}" \ + "REDIS_LAB_FAKE_KUBECONFIG_SCHEMA_VARIANT=${kubeconfig_mutation}" +done + +assert_kubeconfig_render_failure \ + 'kubeconfig chmod failure' \ + REDIS_LAB_FAIL_KUBECONFIG_CHMOD=1 +assert_kubeconfig_render_failure \ + 'kubeconfig mv failure' \ + REDIS_LAB_FAIL_KUBECONFIG_MV=1 + +reset_case +assert_fails run_lab env REDIS_LAB_FAKE_BAD_SERVER_IP=1 "${LAB_SCRIPT}" up +assert_count 3 '^multipass <--purge> <--purge> ' "${FAKE_LOG}" + +reset_case +assert_fails run_lab env REDIS_LAB_HOST_SERVICE_CIDRS='10.53.8.0/24' "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" + +reset_case +assert_fails run_lab env REDIS_LAB_HOST_SERVICE_CIDRS='10.81.1.2/16' "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" + +reset_case +printf '%s\n' 'unrelated-instance' >"${FAKE_INVENTORY}" +run_lab "${LAB_SCRIPT}" run -- scenario-success +assert_count 3 '^multipass <--purge> <--purge> /dev/null || true + fail 'user background child inherited the lifecycle lock' +fi +kill "${BACKGROUND_PID}" 2>/dev/null || true +run_lab "${LAB_SCRIPT}" down + +reset_case +assert_fails run_lab "${LAB_SCRIPT}" run --retain-on-failure -- scenario-failure +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 3 '^ca-redis-lab-' "${FAKE_INVENTORY}" +run_lab "${LAB_SCRIPT}" down + +reset_case +assert_fails env CI=true \ + PATH="${FAKE_BIN}" \ + REDIS_LAB_CONTRACT_TEST=1 \ + REDIS_LAB_HOST_SERVICE_CIDRS='10.81.0.0/16' \ + REDIS_LAB_HOME_DIR="${TEST_HOME_ROOT}" \ + REDIS_LAB_FAKE_LOG="${FAKE_LOG}" \ + REDIS_LAB_FAKE_INVENTORY="${FAKE_INVENTORY}" \ + REDIS_LAB_FAKE_VIOLATIONS="${FAKE_VIOLATIONS}" \ + REDIS_LAB_EXPECTED_STATE="${EXPECTED_STATE}" \ + REDIS_LAB_EXPECTED_KUBECONFIG="${EXPECTED_KUBECONFIG}" \ + REDIS_LAB_EXPECTED_HOST_KUBECONFIG="${EXPECTED_HOST_KUBECONFIG}" \ + "${LAB_SCRIPT}" run --retain-on-failure -- scenario-failure +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" + +reset_case +assert_fails env REDIS_LAB_FAKE_FINGERPRINT_MISMATCH=1 \ + PATH="${FAKE_BIN}" \ + REDIS_LAB_CONTRACT_TEST=1 \ + REDIS_LAB_HOST_SERVICE_CIDRS='10.81.0.0/16' \ + REDIS_LAB_HOME_DIR="${TEST_HOME_ROOT}" \ + REDIS_LAB_FAKE_LOG="${FAKE_LOG}" \ + REDIS_LAB_FAKE_INVENTORY="${FAKE_INVENTORY}" \ + REDIS_LAB_FAKE_VIOLATIONS="${FAKE_VIOLATIONS}" \ + REDIS_LAB_EXPECTED_STATE="${EXPECTED_STATE}" \ + REDIS_LAB_EXPECTED_KUBECONFIG="${EXPECTED_KUBECONFIG}" \ + REDIS_LAB_EXPECTED_HOST_KUBECONFIG="${EXPECTED_HOST_KUBECONFIG}" \ + "${LAB_SCRIPT}" run -- scenario-success +assert_count 3 '^multipass <--purge> ' \ + "${FAKE_LOG}" + +reset_case +assert_fails env REDIS_LAB_FAKE_CIDR_OVERLAP=1 \ + PATH="${FAKE_BIN}" \ + REDIS_LAB_CONTRACT_TEST=1 \ + REDIS_LAB_HOST_SERVICE_CIDRS='10.81.0.0/16' \ + REDIS_LAB_HOME_DIR="${TEST_HOME_ROOT}" \ + REDIS_LAB_FAKE_LOG="${FAKE_LOG}" \ + REDIS_LAB_FAKE_INVENTORY="${FAKE_INVENTORY}" \ + REDIS_LAB_FAKE_VIOLATIONS="${FAKE_VIOLATIONS}" \ + REDIS_LAB_EXPECTED_STATE="${EXPECTED_STATE}" \ + REDIS_LAB_EXPECTED_KUBECONFIG="${EXPECTED_KUBECONFIG}" \ + REDIS_LAB_EXPECTED_HOST_KUBECONFIG="${EXPECTED_HOST_KUBECONFIG}" \ + "${LAB_SCRIPT}" up +assert_count 0 '^multipass ' "${FAKE_LOG}" +assert_count 0 '^multipass ' "${FAKE_LOG}" + +assert_file_not_contains_pattern "${FAKE_LOG}" '^multipass ' +assert_file_not_contains_pattern "${FAKE_LOG}" '^multipass .*<--all>' +assert_file_not_contains_pattern "${FAKE_LOG}" '^multipass .*<[?*]>' +assert_file_not_contains_pattern "${FAKE_LOG}" \ + '^kubectl .*<(apply|create|delete|edit|patch|replace|rollout|scale|taint)>' +[[ ! -s "${FAKE_VIOLATIONS}" ]] || fail 'a fake command rejected an unsafe invocation' + +snapshot_actual_runtime "${ACTUAL_RUNTIME_AFTER}" +cmp -s -- "${ACTUAL_RUNTIME_BEFORE}" "${ACTUAL_RUNTIME_AFTER}" || + fail 'contract modified the actual repository runtime' + +printf '%s\n' 'redis-lab-contract: PASS' diff --git a/infra/redis-lab/versions.env b/infra/redis-lab/versions.env new file mode 100644 index 0000000..501a052 --- /dev/null +++ b/infra/redis-lab/versions.env @@ -0,0 +1,4 @@ +K3S_VERSION=v1.33.3+k3s1 +MULTIPASS_IMAGE=24.04 +K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s +K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc diff --git a/src/.env b/src/.env index a85dc63..ddcd5ae 100644 --- a/src/.env +++ b/src/.env @@ -12,9 +12,20 @@ APP_ERROR_DETAIL_EXPOSURE_ENABLED=false APP_LOG_BODY_CAPTURE_ENABLED=false APP_MULTI_INSTANCE_ENABLED=false APP_MIGRATION_ON_STARTUP=true -APP_RATE_LIMIT_ENABLED=true +APP_RATE_LIMIT_ENABLED=false APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only +APP_RATE_LIMIT_PROVIDER=disabled +APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET= APP_IDEMPOTENCY_TTL=24h +APP_IDEMPOTENCY_PROVIDER=jdbc +APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET= +APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local +APP_IDEMPOTENCY_PROCESSING_LEASE=30s +APP_IDEMPOTENCY_FAILURE_RETENTION=24h +APP_LEASE_PROVIDER=disabled +APP_LEASE_REDIS_KEY_HMAC_SECRET= +APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local +APP_LEASE_REDIS_DRIFT_BUDGET=10ms # ----- Async executor ----- APP_ASYNC_EXECUTOR_CORE_SIZE=10 @@ -22,6 +33,13 @@ APP_ASYNC_EXECUTOR_MAX_SIZE=50 APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200 # ----- Optional integration adapters (default: all disabled) ----- +APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled +# Sentinel primary revalidation cadence for canonically active Sentinel roles. +APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s +# Canonical role semantic readiness: refresh no more often than this interval. +APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s +# Fail closed when the last completed semantic observation is older than this bound. +APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s APP_CACHE_REDIS_ENABLED=false APP_CACHE_REDIS_CLIENT_MODE=managed APP_CACHE_REDIS_HOST=localhost @@ -34,6 +52,13 @@ APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216 APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local APP_CACHE_REDIS_SEMANTIC_REGION=default APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576 +APP_CACHE_REDIS_L1_ENABLED=false +APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000 +APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864 +APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576 +APP_CACHE_REDIS_L1_TTL=30s +APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s +APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024 APP_CACHE_DEFAULT_TTL=300s APP_CACHE_NEGATIVE_TTL=60s APP_MESSAGING_BROKER= @@ -41,23 +66,6 @@ APP_MESSAGING_KAFKA_BROKERS= APP_NOTIFICATION_SLACK_PROVIDER= APP_NOTIFICATION_EMAIL_PROVIDER= -# ----- Outbound HTTP client ----- -APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s -APP_OUTBOUND_HTTP_READ_TIMEOUT=5s -APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s -APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS=128 -APP_OUTBOUND_HTTP_RETRY_ENABLED=false -APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3 -APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms -APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0 -APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false -APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50 -APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100 -APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100 -APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s -APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10 -APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB - # ----- Logging: root & app levels ----- APP_LOG_LEVEL_ROOT=INFO APP_LOG_LEVEL_APP=DEBUG @@ -130,9 +138,26 @@ APP_SERVER_ERROR_INCLUDE_MESSAGE=never PRESENTATION_API_BASE_PATH=/api # ----- Auth (OIDC resource server) ----- +APP_SECURITY_AUTH_MODE=jwt APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api SECURITY_PUBLIC_PATHS=/api/healthcheck +APP_SESSION_COOKIE_NAME=CA_SESSION +APP_SESSION_COOKIE_SECURE=true +APP_SESSION_COOKIE_HTTP_ONLY=true +APP_SESSION_COOKIE_SAME_SITE=Lax +APP_SESSION_COOKIE_PATH=/ +APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN +APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN +APP_SESSION_REDIS_KEY_HMAC_SECRET= +APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local +APP_SESSION_IDLE_TIMEOUT=30m +APP_SESSION_ABSOLUTE_LIFETIME=8h +APP_SESSION_TOUCH_INTERVAL=1m +APP_SESSION_TOMBSTONE_TTL=5m +APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768 +APP_SESSION_MAXIMUM_ATTRIBUTES=64 +APP_SESSION_MAXIMUM_SCALAR_BYTES=8192 # ----- CORS ----- APP_SECURITY_CORS_ENABLED=true diff --git a/src/README.md b/src/README.md index ec2b0b3..9612751 100644 --- a/src/README.md +++ b/src/README.md @@ -204,8 +204,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 **`prod` 에서는 반드시 `false`**, 아니면 기동 실패. - **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede / leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다. -- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화 - (429 + `Retry-After` + `X-RateLimit-*` 응답). +- **`APP_RATE_LIMIT_ENABLED`** — provider-neutral edge rate-limit interceptor 활성화 + (429 + `Retry-After` + `X-RateLimit-*` 응답). 기본값은 `false`이며, `true`로 바꿀 때는 + `APP_RATE_LIMIT_PROVIDER=redis`와 canonical coordination role을 함께 구성해야 합니다. - **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` | `forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만** `forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다. @@ -228,6 +229,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 fail-fast sentinel 이 포트를 충족합니다(Layer 3). - **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`. +- **`APP_CACHE_CANONICAL_DEFAULT_PROVIDER`** — canonical default semantic region 선택. + `disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다. - **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가 제공한 `RedisClient` bean을 사용합니다. - **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시 @@ -240,32 +243,30 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3). ### Outbound HTTP client -- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을 - 강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로 - 보고 즉시 기동을 실패시킵니다. -- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero. -- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero. -- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예: - `10s`), 필수, non-zero. -- **`APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS`** — client별 살아 있는 logical-call worker 상한. - 기본값 `128`, 허용 범위 `1..10000`. -- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이 - 있어야 하며(D3 가드), 없으면 기동 실패. -- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존): - - **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수. - - **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero. - - **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double. -- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면 - `MeterRegistry` 빈 필요(D3), 없으면 기동 실패. -- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j - `ofDefaults()`): - - **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float. - - **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수. - - **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수. - - **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero. - - **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수. -- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를 - 넘는 응답은 streaming API 를 써야 합니다(D7). +현재 canonical activation은 다음 두 설정 트리만 사용합니다. + +```yaml +ca-skeleton: + capabilities: + http-client: + expected-state: DISABLED + bindings: {} + providers: + http-client: {} +``` + +- 기본 `DISABLED`는 binding/provider definition이 모두 비어 있어야 하며 + `DISABLED_VERIFIED`만 게시하고 client, executor, pool, retry/CB registry를 만들지 않습니다. +- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한 + buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에 + fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다. +- 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`, + application YAML과 env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와 + 무관하게 기동을 거부합니다. +- legacy JDK facade가 필요한 fork만 canonical composition 밖에서 + `OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다. + timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider + readiness를 증명하지 않습니다. ### Logging diff --git a/src/adapter/inbound/web/README.md b/src/adapter/inbound/web/README.md index d597be3..6f11583 100644 --- a/src/adapter/inbound/web/README.md +++ b/src/adapter/inbound/web/README.md @@ -47,9 +47,24 @@ production configuration and compare the result with the committed snapshot. ### JwtToAuthenticatedPrincipalConverter - `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며 - `ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인). + `ObjectOutputStream` 으로 round-trip 되지 않는다. Redis session mode에서도 아래 primitive snapshot + repository가 `Authentication` 객체 그래프를 저장하지 않는다. Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다. +### JWT / Redis session 상호배타 모드 + +`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고 +CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only +session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다. + +기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어 +outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다. +`PrimitiveSessionSecurityContextRepository`가 `AuthenticatedPrincipal`의 bounded +principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT, +arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나 +손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음 +요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다. + ### SecurityErrorClassifier - AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가 선언한 세분화 코드를 방출한다. @@ -234,22 +249,20 @@ production configuration and compare the result with the committed snapshot. ## ratelimit -### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter) -- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다. -- **OCP(개방-폐쇄)**: `RateLimitInterceptor` 는 `RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일 - `switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm` - enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`. -- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill) - `X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다. - - `limit` — 설정 quota - - `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor - - `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음 - refill, sliding-window=가장 오래된 카운트 요청 만료 시점) - - `allowed` — quota 소진 시 false (→ 429) -- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를 - 가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance - 전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node, - distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요. +### provider-neutral edge contract + +- inbound web은 `shared-contract`의 `EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와 + provider 설정을 알지 못한다. +- outbound provider activation SSOT는 + `ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은 + `app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을 + 실패시킨다. +- fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다. + 과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가 + 필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며, + silent primary fallback은 허용하지 않는다. +- `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을 + HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다. ### RateLimitKeyResolver - 키 형태: service-to-service @@ -270,11 +283,12 @@ production configuration and compare the result with the committed snapshot. - servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서 resolve 되기 때문(RateLimitKeyResolver 참조). - `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest` - 슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고 + 슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고 슬라이스에선 `Clock#systemUTC()` 로 fallback. ### RateLimitInterceptor -- fixed-window rate limit 을 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true). +- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는 + `X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true). - 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable 의존성 장애로 오분류하는 것을 막는다. @@ -295,12 +309,12 @@ production configuration and compare the result with the committed snapshot. 는 `Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동 시점에 fail-fast 거부. -### RateLimitSettings -- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다. -- `enabled` 는 `APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑. -- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스 - 정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값. - `algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`. +### EdgeRateLimitTransportSettings + +- `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version, + caller deadline, trusted client-IP mode만 소유한다. +- algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로 + 복제하지 않는다. ### SecuritySettings - OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가 diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 2380c62..62837b3 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -6,6 +6,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.session:spring-session-core' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' implementation('org.openapitools:jackson-databind-nullable:0.2.6') { exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind' @@ -15,4 +16,5 @@ dependencies { // never a hand-maintained stale schema). The release-blocking drift gate is // owned by feature-contract-verification-test-suite (planned). implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0' + testImplementation 'org.springframework.security:spring-security-test' } diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index e156e5b..80f0bf9 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -164,7 +164,9 @@ org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runti org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java index e9efb72..5d0228c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java @@ -4,6 +4,7 @@ import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; @@ -24,6 +25,10 @@ import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; * README for the design rationale. */ @Configuration +@ConditionalOnProperty( + name = "ca-skeleton.security.auth-mode", + havingValue = "jwt", + matchIfMissing = true) public class JwtDecoderConfig { @Bean diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java new file mode 100644 index 0000000..c0decce --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java @@ -0,0 +1,255 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpRequestResponseHolder; +import org.springframework.security.web.context.SecurityContextRepository; + +/** + * Stores only a bounded primitive authentication snapshot in {@link HttpSession}. + * + *

Spring Security objects, credentials, tokens and arbitrary principal graphs never cross the + * Spring Session serialization boundary. + */ +final class PrimitiveSessionSecurityContextRepository implements SecurityContextRepository { + + static final String SNAPSHOT_ATTRIBUTE = "dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1"; + + private static final int MAGIC = 0x43534543; + private static final int VERSION = 1; + private static final int MAXIMUM_SNAPSHOT_BYTES = 16_384; + private static final int MAXIMUM_PRINCIPAL_BYTES = 256; + private static final int MAXIMUM_EMAIL_BYTES = 320; + private static final int MAXIMUM_TOKEN_BYTES = 128; + private static final int MAXIMUM_ROLES = 64; + private static final int MAXIMUM_AUTHORITIES = 128; + + @Override + public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { + return load(requestResponseHolder.getRequest()); + } + + @Override + public void saveContext( + SecurityContext context, HttpServletRequest request, HttpServletResponse response) { + Objects.requireNonNull(request, "request"); + Authentication authentication = context == null ? null : context.getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + HttpSession existing = request.getSession(false); + if (existing != null) { + existing.removeAttribute(SNAPSHOT_ATTRIBUTE); + } + return; + } + request.getSession(true).setAttribute(SNAPSHOT_ATTRIBUTE, encode(authentication)); + } + + @Override + public boolean containsContext(HttpServletRequest request) { + HttpSession session = request.getSession(false); + return session != null && session.getAttribute(SNAPSHOT_ATTRIBUTE) instanceof byte[]; + } + + private static SecurityContext load(HttpServletRequest request) { + SecurityContext empty = SecurityContextHolder.createEmptyContext(); + HttpSession session = request.getSession(false); + if (session == null) { + return empty; + } + Object stored = session.getAttribute(SNAPSHOT_ATTRIBUTE); + if (!(stored instanceof byte[] snapshot)) { + return empty; + } + try { + PrimitiveAuthentication decoded = decode(snapshot); + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal(decoded.principalId, decoded.email, decoded.roles); + List authorities = + decoded.authorities.stream() + .map(SimpleGrantedAuthority::new) + .map(GrantedAuthority.class::cast) + .toList(); + empty.setAuthentication( + UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities)); + return empty; + } catch (IllegalArgumentException exception) { + session.removeAttribute(SNAPSHOT_ATTRIBUTE); + return empty; + } + } + + private static byte[] encode(Authentication authentication) { + if (!(authentication.getPrincipal() instanceof AuthenticatedPrincipal principal)) { + throw new IllegalArgumentException( + "redis-session authentication requires an AuthenticatedPrincipal"); + } + Set roles = boundedTokens(principal.roles(), MAXIMUM_ROLES, "roles"); + Set authorities = + boundedTokens( + authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(), + MAXIMUM_AUTHORITIES, + "authorities"); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(MAGIC); + output.writeByte(VERSION); + writeText(output, principal.idpUserId(), MAXIMUM_PRINCIPAL_BYTES, "principal ID"); + writeNullableText(output, principal.email(), MAXIMUM_EMAIL_BYTES, "email"); + writeTokens(output, roles); + writeTokens(output, authorities); + } + byte[] snapshot = bytes.toByteArray(); + if (snapshot.length > MAXIMUM_SNAPSHOT_BYTES) { + throw new IllegalArgumentException("security context snapshot exceeds the byte bound"); + } + return snapshot; + } catch (IOException exception) { + throw new IllegalStateException("in-memory security context encoding failed", exception); + } + } + + private static PrimitiveAuthentication decode(byte[] snapshot) { + if (snapshot.length < 1 || snapshot.length > MAXIMUM_SNAPSHOT_BYTES) { + throw invalidSnapshot(); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(snapshot.clone()))) { + if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) { + throw invalidSnapshot(); + } + String principalId = readText(input, MAXIMUM_PRINCIPAL_BYTES); + String email = readNullableText(input, MAXIMUM_EMAIL_BYTES); + Set roles = readTokens(input, MAXIMUM_ROLES); + Set authorities = readTokens(input, MAXIMUM_AUTHORITIES); + if (input.available() != 0) { + throw invalidSnapshot(); + } + return new PrimitiveAuthentication(principalId, email, roles, authorities); + } catch (IOException | IllegalArgumentException exception) { + throw invalidSnapshot(); + } + } + + private static void writeTokens(DataOutputStream output, Set values) throws IOException { + output.writeInt(values.size()); + for (String value : values) { + writeText(output, value, MAXIMUM_TOKEN_BYTES, "security token"); + } + } + + private static Set readTokens(DataInputStream input, int maximumCount) + throws IOException { + int count = input.readInt(); + if (count < 0 || count > maximumCount) { + throw invalidSnapshot(); + } + Set values = new LinkedHashSet<>(); + for (int index = 0; index < count; index++) { + if (!values.add(readText(input, MAXIMUM_TOKEN_BYTES))) { + throw invalidSnapshot(); + } + } + return Set.copyOf(values); + } + + private static Set boundedTokens( + Collection values, int maximumCount, String field) { + if (values == null || values.size() > maximumCount) { + throw new IllegalArgumentException(field + " exceed the configured count bound"); + } + TreeSet bounded = new TreeSet<>(); + for (String value : values) { + requireBoundedText(value, MAXIMUM_TOKEN_BYTES, field); + bounded.add(value); + } + return Set.copyOf(bounded); + } + + private static void writeNullableText( + DataOutputStream output, String value, int maximumBytes, String field) throws IOException { + output.writeBoolean(value != null); + if (value != null) { + writeText(output, value, maximumBytes, field); + } + } + + private static String readNullableText(DataInputStream input, int maximumBytes) + throws IOException { + return input.readBoolean() ? readText(input, maximumBytes) : null; + } + + private static void writeText( + DataOutputStream output, String value, int maximumBytes, String field) throws IOException { + byte[] encoded = requireBoundedText(value, maximumBytes, field); + output.writeInt(encoded.length); + output.write(encoded); + } + + private static String readText(DataInputStream input, int maximumBytes) throws IOException { + int length = input.readInt(); + if (length < 1 || length > maximumBytes || length > input.available()) { + throw new EOFException("invalid security context text length"); + } + byte[] encoded = input.readNBytes(length); + String value = new String(encoded, StandardCharsets.UTF_8); + byte[] canonical = requireBoundedText(value, maximumBytes, "decoded value"); + if (!java.util.Arrays.equals(canonical, encoded)) { + throw invalidSnapshot(); + } + return value; + } + + private static byte[] requireBoundedText(String value, int maximumBytes, String field) { + if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " must be non-blank text without controls"); + } + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length > maximumBytes) { + throw new IllegalArgumentException(field + " exceeds the UTF-8 byte bound"); + } + return encoded; + } + + private static IllegalArgumentException invalidSnapshot() { + return new IllegalArgumentException("security context snapshot is corrupt or incompatible"); + } + + private static final class PrimitiveAuthentication { + + private final String principalId; + private final String email; + private final Set roles; + private final Set authorities; + + private PrimitiveAuthentication( + String principalId, String email, Set roles, Set authorities) { + this.principalId = principalId; + this.email = email; + this.roles = roles; + this.authorities = authorities; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java new file mode 100644 index 0000000..890e5c6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; + +/** Provider-neutral servlet session filter and hardened host-only cookie composition. */ +@Configuration(proxyBeanMethods = false) +@EnableSpringHttpSession +@ConditionalOnProperty( + name = "ca-skeleton.security.auth-mode", + havingValue = "redis-session", + matchIfMissing = false) +public class RedisSessionWebConfig { + + @Bean + CookieSerializer sessionCookieSerializer(SecuritySettings settings) { + SecuritySettings.SessionCookieSettings policy = settings.session(); + DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setCookieName(policy.cookieName()); + serializer.setUseSecureCookie(policy.secure()); + serializer.setUseHttpOnlyCookie(policy.httpOnly()); + serializer.setSameSite(policy.sameSite()); + serializer.setCookiePath(policy.path()); + serializer.setCookieMaxAge(-1); + serializer.setUseBase64Encoding(true); + // No domain or domain pattern is configured: the session cookie remains host-only. + return serializer; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index 317719a..d53968b 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -2,6 +2,7 @@ package dev.caskeleton.adapter.inbound.web.auth; import dev.caskeleton.adapter.inbound.web.settings.CorsSettings; import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -10,6 +11,8 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -49,19 +52,28 @@ public class SecurityConfig { return new EnvelopeAccessDeniedHandler(classifier, objectMapper); } + @Bean + @ConditionalOnProperty( + name = "ca-skeleton.security.auth-mode", + havingValue = "redis-session", + matchIfMissing = false) + PrimitiveSessionSecurityContextRepository primitiveSessionSecurityContextRepository() { + return new PrimitiveSessionSecurityContextRepository(); + } + @Bean public SecurityFilterChain filterChain( HttpSecurity http, AuthenticationEntryPoint authenticationEntryPoint, - AccessDeniedHandler accessDeniedHandler) + AccessDeniedHandler accessDeniedHandler, + org.springframework.beans.factory.ObjectProvider + sessionSecurityContextRepository) throws Exception { String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]); - http.csrf(csrf -> csrf.disable()) - .cors(c -> c.configurationSource(corsConfigurationSource())) + http.cors(c -> c.configurationSource(corsConfigurationSource())) // Disable Spring Security's default Cache-Control writer; CacheControlFilter // owns the cache header policy. See README for the design rationale. .headers(headers -> headers.cacheControl(cache -> cache.disable())) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests( auth -> { if (publicPaths.length > 0) { @@ -75,13 +87,45 @@ public class SecurityConfig { .exceptionHandling( ex -> ex.authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler)) - .oauth2ResourceServer( - oauth -> - oauth - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler) - .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))); + .accessDeniedHandler(accessDeniedHandler)); + if (securitySettings.authMode() == SecuritySettings.AuthenticationMode.JWT) { + http.csrf(csrf -> csrf.disable()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .oauth2ResourceServer( + oauth -> + oauth + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))); + } else { + SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session(); + CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository(); + csrfRepository.setCookieName(sessionSettings.csrfCookieName()); + csrfRepository.setHeaderName(sessionSettings.csrfHeaderName()); + csrfRepository.setCookieCustomizer( + cookie -> + cookie + .secure(true) + .httpOnly(false) + .sameSite(sessionSettings.sameSite()) + .path(sessionSettings.path())); + CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler(); + http.csrf( + csrf -> + csrf.csrfTokenRepository(csrfRepository) + .csrfTokenRequestHandler(csrfRequestHandler)) + .sessionManagement( + session -> + session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .sessionFixation(fixation -> fixation.migrateSession())) + .securityContext( + securityContext -> + securityContext + .securityContextRepository(sessionSecurityContextRepository.getObject()) + .requireExplicitSave(false)); + } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java new file mode 100644 index 0000000..00303de --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject; +import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Provider-neutral bridge from an HTTP request to {@link EdgeRateLimitPort}. + * + *

Raw principal, API-key identity, client IP, and route values stop at the pseudonymizer. Only + * the versioned digest and bounded enforcement metadata cross the provider boundary. + */ +public final class EdgeRateLimitTransportBridge { + + private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}"); + private static final Duration MAXIMUM_CALLER_DEADLINE_BUDGET = Duration.ofSeconds(30); + + private final EdgeRateLimitPort port; + private final EdgeSubjectPseudonymizer pseudonymizer; + private final RateLimitKeyResolver subjectResolver; + private final Clock clock; + private final String policyId; + private final Duration callerDeadlineBudget; + private final RateLimitEvaluationIdGenerator evaluationIdGenerator; + + public EdgeRateLimitTransportBridge( + EdgeRateLimitPort port, + EdgeSubjectPseudonymizer pseudonymizer, + RateLimitKeyResolver subjectResolver, + Clock clock, + String policyId, + Duration callerDeadlineBudget, + RateLimitEvaluationIdGenerator evaluationIdGenerator) { + this.port = Objects.requireNonNull(port, "port must not be null"); + this.pseudonymizer = Objects.requireNonNull(pseudonymizer, "pseudonymizer must not be null"); + this.subjectResolver = + Objects.requireNonNull(subjectResolver, "subjectResolver must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + if (policyId == null || !POLICY_ID.matcher(policyId).matches()) { + throw new IllegalArgumentException("policyId must be a bounded policy identifier"); + } + this.policyId = policyId; + this.callerDeadlineBudget = positiveBoundedBudget(callerDeadlineBudget, "callerDeadlineBudget"); + this.evaluationIdGenerator = + Objects.requireNonNull(evaluationIdGenerator, "evaluationIdGenerator must not be null"); + } + + public RateLimitOutcome evaluate(HttpServletRequest request) { + Objects.requireNonNull(request, "request must not be null"); + EdgeRateLimitSubject rawSubject = subjectResolver.resolve(request); + RateLimitSubjectDigest subjectDigest = + Objects.requireNonNull( + pseudonymizer.pseudonymize(rawSubject), "pseudonymizer must return a subject digest"); + Instant callerDeadline = clock.instant().plus(callerDeadlineBudget); + String evaluationId = + Objects.requireNonNull( + evaluationIdGenerator.generate(), "evaluationIdGenerator must return an evaluation ID"); + return Objects.requireNonNull( + port.evaluate( + new RateLimitRequest(policyId, subjectDigest, 1, evaluationId, callerDeadline)), + "rate-limit port must return an outcome"); + } + + static Duration positiveBoundedBudget(Duration value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + if (value.isZero() + || value.isNegative() + || value.compareTo(MAXIMUM_CALLER_DEADLINE_BUDGET) > 0) { + throw new IllegalArgumentException(field + " must be positive and no more than 30 seconds"); + } + long milliseconds = value.toMillis(); + if (!Duration.ofMillis(milliseconds).equals(value)) { + throw new IllegalArgumentException(field + " must use whole milliseconds"); + } + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettings.java new file mode 100644 index 0000000..980c68b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettings.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.time.Duration; +import java.util.regex.Pattern; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * HTTP bridge settings bound to the transport-only {@code app.rate-limit} axis. + * + *

The outbound provider is selected independently by {@code + * ca-skeleton.capabilities.rate-limit.provider}; enabling this bridge never selects a provider or a + * fallback. + */ +@ConfigurationProperties(prefix = "app.rate-limit") +public record EdgeRateLimitTransportSettings( + boolean enabled, + String defaultPolicyId, + Duration callerDeadlineBudget, + int hashKeyVersion, + RateLimitClientIpMode clientIpMode) { + + private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}"); + + public EdgeRateLimitTransportSettings { + defaultPolicyId = + defaultPolicyId == null || defaultPolicyId.isBlank() ? "api-default" : defaultPolicyId; + callerDeadlineBudget = + callerDeadlineBudget == null ? Duration.ofSeconds(2) : callerDeadlineBudget; + hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion; + clientIpMode = clientIpMode == null ? RateLimitClientIpMode.REMOTE_ADDR_ONLY : clientIpMode; + if (!POLICY_ID.matcher(defaultPolicyId).matches()) { + throw new IllegalArgumentException("defaultPolicyId must be a bounded policy identifier"); + } + EdgeRateLimitTransportBridge.positiveBoundedBudget( + callerDeadlineBudget, "callerDeadlineBudget"); + if (hashKeyVersion < 1 || hashKeyVersion > 9999) { + throw new IllegalArgumentException("hashKeyVersion must be in 1..9999"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java deleted file mode 100644 index 81890e4..0000000 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window - * {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the - * design rationale. - */ -public final class FixedWindowRateLimiter implements RateLimiter { - - private final int limit; - private final long windowSeconds; - private final Clock clock; - private final ConcurrentMap windows = new ConcurrentHashMap<>(); - - public FixedWindowRateLimiter(int limit, Duration window, Clock clock) { - this.limit = Math.max(1, limit); - this.windowSeconds = Math.max(1L, window.toSeconds()); - this.clock = clock; - } - - @Override - public RateLimitDecision decide(String key) { - long nowSecond = clock.instant().getEpochSecond(); - long windowId = nowSecond / windowSeconds; - Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds); - - Window window = - windows.compute( - key, - (k, current) -> - (current == null || current.id != windowId) ? new Window(windowId) : current); - - int count = window.count.incrementAndGet(); - boolean allowed = count <= limit; - int remaining = Math.max(0, limit - count); - return new RateLimitDecision(allowed, limit, remaining, resetAt); - } - - private static final class Window { - private final long id; - private final AtomicInteger count = new AtomicInteger(); - - private Window(long id) { - this.id = id; - } - } -} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java deleted file mode 100644 index e21d57e..0000000 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -/** - * Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README - * for the design rationale. - */ -public enum RateLimitAlgorithm { - - /** Fixed-window counter — the default single-node implementation. */ - FIXED_WINDOW -} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java deleted file mode 100644 index a1e9b44..0000000 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -import java.time.Instant; - -/** - * Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*} - * signaling headers. See README for the design rationale. - * - * @param allowed false when the caller has exceeded the limit this window (→ 429) - * @param limit the window quota ({@code X-RateLimit-Limit}) - * @param remaining requests left in the current window, floored at 0 ({@code - * X-RateLimit-Remaining}) - * @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339) - */ -public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitEvaluationIdGenerator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitEvaluationIdGenerator.java new file mode 100644 index 0000000..0726c4d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitEvaluationIdGenerator.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +/** Server-owned source of per-evaluation replay identifiers. */ +@FunctionalInterface +public interface RateLimitEvaluationIdGenerator { + + String generate(); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java index d64d366..19e7cf3 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java @@ -2,73 +2,111 @@ package dev.caskeleton.adapter.inbound.web.ratelimit; import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.shared.error.ApiErrorCode; import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; import dev.caskeleton.shared.response.Envelope; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; import java.time.format.DateTimeFormatter; +import java.util.Objects; import org.springframework.http.MediaType; import org.springframework.web.servlet.HandlerInterceptor; import tools.jackson.databind.ObjectMapper; /** - * Applies the rate limit before a mapped handler runs. Every response carries the {@code - * X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429 - * {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers. - * See README for the design rationale. + * Maps provider-neutral rate-limit outcomes to the stable HTTP signaling contract. + * + *

Disabled instances have no bridge and therefore cannot resolve a subject, pseudonymize, or + * invoke a provider. */ public final class RateLimitInterceptor implements HandlerInterceptor { - private static final String CLIENT_SAFE_MESSAGE = + private static final String DENIED_MESSAGE = "Too many requests, please retry after the indicated interval"; + private static final String UNAVAILABLE_MESSAGE = + "Rate-limit enforcement is temporarily unavailable"; + private static final String INCOMPATIBLE_MESSAGE = + "Rate-limit enforcement is unavailable due to an incompatible provider"; - private final boolean enabled; - private final RateLimiter limiter; - private final RateLimitKeyResolver keyResolver; + private final EdgeRateLimitTransportBridge bridge; private final ObjectMapper objectMapper; - private final int retryAfterSeconds; - public RateLimitInterceptor( - boolean enabled, - RateLimiter limiter, - RateLimitKeyResolver keyResolver, - ObjectMapper objectMapper, - int retryAfterSeconds) { - this.enabled = enabled; - this.limiter = limiter; - this.keyResolver = keyResolver; - this.objectMapper = objectMapper; - this.retryAfterSeconds = retryAfterSeconds; + private RateLimitInterceptor(EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) { + this.bridge = bridge; + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + } + + public static RateLimitInterceptor enabled( + EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) { + return new RateLimitInterceptor( + Objects.requireNonNull(bridge, "bridge must not be null"), objectMapper); + } + + public static RateLimitInterceptor disabled(ObjectMapper objectMapper) { + return new RateLimitInterceptor(null, objectMapper); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - if (!enabled) { + if (bridge == null) { return true; } - RateLimitDecision decision = limiter.decide(keyResolver.resolve(request)); + return switch (bridge.evaluate(request)) { + case RateLimitOutcome.Evaluated evaluated -> handleEvaluated(response, evaluated.decision()); + case RateLimitOutcome.Unavailable unavailable -> + rejectUnavailable(response, unavailable.retryAfter()); + case RateLimitOutcome.Indeterminate indeterminate -> + rejectUnavailable(response, indeterminate.retryAfter()); + case RateLimitOutcome.Incompatible incompatible -> rejectIncompatible(response); + }; + } + + private boolean handleEvaluated(HttpServletResponse response, RateLimitDecision decision) + throws Exception { applySignalingHeaders(response, decision); if (decision.allowed()) { return true; } - rejectWith429(response); + response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(decision.retryAfter())); + reject(response, OperationalError.RATE_LIMIT_EXCEEDED, DENIED_MESSAGE); return false; } - private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) { - response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit())); - response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining())); + private boolean rejectUnavailable(HttpServletResponse response, Duration retryAfter) + throws Exception { + response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(retryAfter)); + reject(response, RateLimitTransportError.RATE_LIMIT_UNAVAILABLE, UNAVAILABLE_MESSAGE); + return false; + } + + private boolean rejectIncompatible(HttpServletResponse response) throws Exception { + reject(response, RateLimitTransportError.RATE_LIMIT_INCOMPATIBLE, INCOMPATIBLE_MESSAGE); + return false; + } + + private static void applySignalingHeaders( + HttpServletResponse response, RateLimitDecision decision) { + response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Long.toString(decision.limit())); + response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Long.toString(decision.remaining())); response.setHeader( ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt())); } - private void rejectWith429(HttpServletResponse response) throws Exception { - response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus()); - response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds)); + private void reject(HttpServletResponse response, ApiErrorCode error, String message) + throws Exception { + response.setStatus(error.httpStatus()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - Envelope body = - ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null); + Envelope body = ErrorResponseFactory.body(error, message, null); objectMapper.writeValue(response.getWriter(), body); } + + private static String retryAfterSeconds(Duration retryAfter) { + long milliseconds = retryAfter.toMillis(); + long seconds = Math.floorDiv(milliseconds + 999, 1000); + return Long.toString(seconds); + } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java index 489148e..a475a46 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java @@ -1,21 +1,24 @@ package dev.caskeleton.adapter.inbound.web.ratelimit; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject; import jakarta.servlet.http.HttpServletRequest; +import java.util.Locale; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.servlet.HandlerMapping; /** - * Derives the rate-limit key from a request: + * Derives a bounded pre-pseudonymization rate-limit subject from a request: * *

    - *
  • authenticated user → {@code user:} - *
  • service-to-service (a {@code service}-role principal) → {@code apikey:} - *
  • unauthenticated → {@code ip::} + *
  • authenticated user → principal + operation + *
  • service-to-service (a {@code service}-role principal) → API key + operation + *
  • unauthenticated → client IP + operation *
* - *

See README for the design rationale. + *

The returned raw identity exists only until {@link EdgeSubjectPseudonymizer} runs. It must not + * cross the provider port boundary. */ public final class RateLimitKeyResolver { @@ -27,19 +30,37 @@ public final class RateLimitKeyResolver { this.clientIpResolver = clientIpResolver; } - public String resolve(HttpServletRequest request) { + public EdgeRateLimitSubject resolve(HttpServletRequest request) { + String operationId = operationId(request); Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof AuthenticatedPrincipal user) { - return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId(); + EdgeRateLimitSubject.Kind kind = + user.hasRole(SERVICE_ROLE) + ? EdgeRateLimitSubject.Kind.API_KEY + : EdgeRateLimitSubject.Kind.PRINCIPAL; + return new EdgeRateLimitSubject(kind, user.idpUserId(), operationId); } - return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request); + return new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.CLIENT_IP, clientIpResolver.resolve(request), operationId); } - private static String routeTemplate(HttpServletRequest request) { + private static String operationId(HttpServletRequest request) { + String method = normalizedMethod(request.getMethod()); Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); - String route = pattern instanceof String s ? s : request.getRequestURI(); - return request.getMethod() + " " + route; + String route = + pattern instanceof String value && !value.isBlank() ? value : ""; + return method + " " + route; + } + + private static String normalizedMethod(String method) { + if (method == null + || method.isBlank() + || method.length() > 16 + || !method.chars().allMatch(Character::isLetter)) { + return "OTHER"; + } + return method.toUpperCase(Locale.ROOT); } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitTransportError.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitTransportError.java new file mode 100644 index 0000000..651e860 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitTransportError.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +/** HTTP-only mapping codes for provider outcomes that do not contain an allow/deny decision. */ +enum RateLimitTransportError implements ApiErrorCode { + RATE_LIMIT_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, true), + RATE_LIMIT_INCOMPATIBLE(Category.INTERNAL, false); + + private final Category category; + private final boolean retryable; + + RateLimitTransportError(Category category, boolean retryable) { + this.category = category; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return 503; + } + + @Override + public boolean retryable() { + return retryable; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java index ba7ec3c..b460c3c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java @@ -1,8 +1,7 @@ package dev.caskeleton.adapter.inbound.web.ratelimit; -import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor; -import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings; -import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; import java.time.Clock; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -12,39 +11,57 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import tools.jackson.databind.ObjectMapper; /** - * Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken - * from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With - * no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a - * pass-through. See README for the design rationale. + * Wires provider-neutral edge enforcement into MVC. + * + *

Transport activation, trusted client-IP selection, policy selection, and deadlines come from + * {@code app.rate-limit}. Provider activation is a separate composition-root decision; an enabled + * bridge requires exactly one semantic port and never installs a local fallback. */ @Configuration -@EnableConfigurationProperties(RateLimitSettings.class) +@EnableConfigurationProperties(EdgeRateLimitTransportSettings.class) public class RateLimitWebConfig implements WebMvcConfigurer { private final RateLimitInterceptor rateLimitInterceptor; public RateLimitWebConfig( - RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider clock) { - RateLimiter limiter = - RateLimiterFactory.create( - properties.algorithm(), - properties.limit(), - properties.window(), - clock.getIfAvailable(Clock::systemUTC)); - int retryAfter = - RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1); - ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode()); - this.rateLimitInterceptor = - new RateLimitInterceptor( - properties.enabled(), - limiter, - new RateLimitKeyResolver(clientIpResolver), - objectMapper, - retryAfter); + EdgeRateLimitTransportSettings transportSettings, + ObjectMapper objectMapper, + ObjectProvider clockProvider, + ObjectProvider portProvider, + ObjectProvider pseudonymizerProvider) { + if (!transportSettings.enabled()) { + this.rateLimitInterceptor = RateLimitInterceptor.disabled(objectMapper); + return; + } + + EdgeRateLimitPort port = requiredUnique(portProvider, "EdgeRateLimitPort"); + UserPrincipalPseudonymizerPort secretBackedPseudonymizer = + requiredUnique(pseudonymizerProvider, "UserPrincipalPseudonymizerPort"); + EdgeRateLimitTransportBridge bridge = + new EdgeRateLimitTransportBridge( + port, + new VersionedEdgeSubjectPseudonymizer( + secretBackedPseudonymizer, transportSettings.hashKeyVersion()), + new RateLimitKeyResolver( + ClientIpResolverFactory.create(transportSettings.clientIpMode())), + clockProvider.getIfAvailable(Clock::systemUTC), + transportSettings.defaultPolicyId(), + transportSettings.callerDeadlineBudget(), + SecureRandomRateLimitEvaluationIdGenerator.versionOne()); + this.rateLimitInterceptor = RateLimitInterceptor.enabled(bridge, objectMapper); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(rateLimitInterceptor); } + + private static T requiredUnique(ObjectProvider provider, String capability) { + T instance = provider.getIfUnique(); + if (instance == null) { + throw new IllegalStateException( + capability + " must have exactly one bean when edge rate limiting is enabled"); + } + return instance; + } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java deleted file mode 100644 index bf59881..0000000 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -/** - * Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code - * X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design - * rationale and the algorithm-neutral output contract. - */ -public interface RateLimiter { - - /** Register one request for {@code key} and report whether it is within the limit. */ - RateLimitDecision decide(String key); -} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java deleted file mode 100644 index e383b66..0000000 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -import java.time.Clock; -import java.time.Duration; - -/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */ -public final class RateLimiterFactory { - - private RateLimiterFactory() {} - - public static RateLimiter create( - RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) { - return switch (algorithm) { - case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock); - }; - } -} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGenerator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGenerator.java new file mode 100644 index 0000000..b0a9a5c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGenerator.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; + +/** + * Cryptographically random evaluation ID generator. + * + *

IDs are created only by the server. HTTP headers and request bodies are never consulted. + */ +public final class SecureRandomRateLimitEvaluationIdGenerator + implements RateLimitEvaluationIdGenerator { + + private static final int RANDOM_BYTES = 16; + + private final SecureRandom secureRandom; + private final String prefix; + + public SecureRandomRateLimitEvaluationIdGenerator(SecureRandom secureRandom, int version) { + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null"); + if (version < 1 || version > 9999) { + throw new IllegalArgumentException("evaluation ID version must be in 1..9999"); + } + this.prefix = "ev" + version + ":"; + } + + public static SecureRandomRateLimitEvaluationIdGenerator versionOne() { + return new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1); + } + + @Override + public String generate() { + byte[] random = new byte[RANDOM_BYTES]; + secureRandom.nextBytes(random); + return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java new file mode 100644 index 0000000..7afd8c2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject; +import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer; +import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Adapts the application-provided secret-backed HMAC capability to the edge subject contract. + * + *

The adapter length-frames each dimension before hashing and adds an explicit key-rotation + * version to the resulting digest. It does not resolve or retain the HMAC secret. + */ +final class VersionedEdgeSubjectPseudonymizer implements EdgeSubjectPseudonymizer { + + private final UserPrincipalPseudonymizerPort delegate; + private final int version; + + VersionedEdgeSubjectPseudonymizer(UserPrincipalPseudonymizerPort delegate, int version) { + this.delegate = Objects.requireNonNull(delegate, "delegate must not be null"); + if (version < 1 || version > 9999) { + throw new IllegalArgumentException("subject digest version must be in 1..9999"); + } + this.version = version; + } + + @Override + public RateLimitSubjectDigest pseudonymize(EdgeRateLimitSubject subject) { + Objects.requireNonNull(subject, "subject must not be null"); + String canonical = + frame(subject.kind().name()) + + "|" + + frame(subject.canonicalIdentity()) + + "|" + + frame(subject.operationId()); + String digest = delegate.pseudonymize(canonical); + return new RateLimitSubjectDigest("v" + version + ":" + digest); + } + + private static String frame(String value) { + return value.getBytes(StandardCharsets.UTF_8).length + ":" + value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java deleted file mode 100644 index d54838a..0000000 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.settings; - -import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm; -import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode; -import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -/** - * Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design - * rationale. - * - * @param enabled whether the rate-limit interceptor enforces limits - * @param limit max requests allowed per key within one window - * @param window the fixed time window over which {@code limit} is counted - * @param algorithm the rate-limit strategy to use - * @param clientIpMode client-IP source for unauthenticated rate-limit keys - */ -@Validated -@ConfigurationProperties(prefix = "ca-skeleton.rate-limit") -public record RateLimitSettings( - boolean enabled, - Integer limit, - Duration window, - RateLimitAlgorithm algorithm, - RateLimitClientIpMode clientIpMode) { - - public RateLimitSettings { - if (limit == null || limit < 1) { - limit = 100; - } - if (window == null || window.isZero() || window.isNegative()) { - window = Duration.ofSeconds(1); - } - if (algorithm == null) { - algorithm = RateLimitAlgorithm.FIXED_WINDOW; - } - if (clientIpMode == null) { - clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY; - } - } -} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java index 67c32ec..6e3a636 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java @@ -4,29 +4,107 @@ import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; /** - * OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design - * rationale. + * Exclusive JWT or Redis-backed browser-session security policy bound from {@code + * ca-skeleton.security.*}. */ @ConfigurationProperties(prefix = "ca-skeleton.security") -public record SecuritySettings(String issuerUri, String audience, List publicPaths) { +public record SecuritySettings( + AuthenticationMode authMode, + String issuerUri, + String audience, + List publicPaths, + SessionCookieSettings session) { private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class); - public SecuritySettings { - if (issuerUri == null || issuerUri.isBlank()) { + @ConstructorBinding + public SecuritySettings( + AuthenticationMode authMode, + String issuerUri, + String audience, + List publicPaths, + SessionCookieSettings session) { + this.authMode = authMode == null ? AuthenticationMode.JWT : authMode; + if (this.authMode == AuthenticationMode.JWT && (issuerUri == null || issuerUri.isBlank())) { throw new IllegalArgumentException( "APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required"); } + this.issuerUri = issuerUri == null ? "" : issuerUri.trim(); if (audience == null) { - log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation"); - audience = ""; + if (this.authMode == AuthenticationMode.JWT) { + log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation"); + } + this.audience = ""; + } else { + this.audience = audience.trim(); } if (publicPaths == null) { - publicPaths = List.of(); + this.publicPaths = List.of(); } else { - publicPaths = List.copyOf(publicPaths); + this.publicPaths = List.copyOf(publicPaths); + } + this.session = session == null ? SessionCookieSettings.defaults() : session; + } + + public SecuritySettings(String issuerUri, String audience, List publicPaths) { + this(AuthenticationMode.JWT, issuerUri, audience, publicPaths, null); + } + + public enum AuthenticationMode { + JWT, + REDIS_SESSION + } + + public record SessionCookieSettings( + String cookieName, + Boolean secure, + Boolean httpOnly, + String sameSite, + String path, + String csrfCookieName, + String csrfHeaderName) { + + public SessionCookieSettings( + String cookieName, + Boolean secure, + Boolean httpOnly, + String sameSite, + String path, + String csrfCookieName, + String csrfHeaderName) { + this.cookieName = safeName(cookieName, "CA_SESSION", "cookieName"); + this.secure = secure == null || secure; + this.httpOnly = httpOnly == null || httpOnly; + this.sameSite = sameSite == null || sameSite.isBlank() ? "Lax" : sameSite; + if (!this.sameSite.matches("Lax|Strict|None")) { + throw new IllegalArgumentException("session sameSite must be Lax, Strict, or None"); + } + this.path = path == null || path.isBlank() ? "/" : path; + if (!this.path.startsWith("/") + || this.path.length() > 128 + || this.path.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("session cookie path must be a bounded absolute path"); + } + this.csrfCookieName = safeName(csrfCookieName, "XSRF-TOKEN", "csrfCookieName"); + this.csrfHeaderName = safeName(csrfHeaderName, "X-XSRF-TOKEN", "csrfHeaderName"); + if (!this.secure || !this.httpOnly) { + throw new IllegalArgumentException("Redis session cookie must remain Secure and HttpOnly"); + } + } + + private static SessionCookieSettings defaults() { + return new SessionCookieSettings(null, null, null, null, null, null, null); + } + + private static String safeName(String value, String fallback, String field) { + String resolved = value == null || value.isBlank() ? fallback : value; + if (!resolved.matches("[A-Za-z][A-Za-z0-9_-]{1,63}")) { + throw new IllegalArgumentException(field + " must be a bounded cookie/header token"); + } + return resolved; } } } diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java new file mode 100644 index 0000000..0edf1ad --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpRequestResponseHolder; + +class PrimitiveSessionSecurityContextRepositoryTest { + + private final PrimitiveSessionSecurityContextRepository repository = + new PrimitiveSessionSecurityContextRepository(); + + @Test + void roundTripsOnlyABoundedPrimitiveSnapshotWithoutCredentialsOrFrameworkObjects() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + var context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + new AuthenticatedPrincipal( + "idp-user-42", "user@example.test", Set.of("operator", "auditor")), + "must-never-be-stored", + Set.of( + new SimpleGrantedAuthority("ROLE_OPERATOR"), + new SimpleGrantedAuthority("worklog:read")))); + + repository.saveContext(context, request, response); + + Object stored = + request + .getSession(false) + .getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE); + assertThat(stored).isInstanceOf(byte[].class); + assertThat(request.getSession(false).getAttribute("SPRING_SECURITY_CONTEXT")).isNull(); + var loaded = + repository + .loadContext(new HttpRequestResponseHolder(request, response)) + .getAuthentication(); + assertThat(loaded.getCredentials()).isNull(); + assertThat(loaded.getPrincipal()) + .isEqualTo( + new AuthenticatedPrincipal( + "idp-user-42", "user@example.test", Set.of("operator", "auditor"))); + assertThat(loaded.getAuthorities()) + .extracting(authority -> authority.getAuthority()) + .containsExactlyInAnyOrder("ROLE_OPERATOR", "worklog:read"); + } + + @Test + void rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + var foreign = SecurityContextHolder.createEmptyContext(); + foreign.setAuthentication( + UsernamePasswordAuthenticationToken.authenticated(new Object(), "credential", Set.of())); + + assertThatThrownBy(() -> repository.saveContext(foreign, request, response)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("AuthenticatedPrincipal"); + + request + .getSession(true) + .setAttribute( + PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE, + new byte[] {0x01, 0x02, 0x03}); + assertThat( + repository + .loadContext(new HttpRequestResponseHolder(request, response)) + .getAuthentication()) + .isNull(); + assertThat( + request + .getSession(false) + .getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE)) + .isNull(); + } + + @Test + void rejectsAuthorityCountsBeyondThePublishedBound() { + MockHttpServletRequest request = new MockHttpServletRequest(); + var authorities = + IntStream.range(0, 129) + .mapToObj(index -> new SimpleGrantedAuthority("authority-" + index)) + .toList(); + var context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + new AuthenticatedPrincipal("idp-user-42", null, Set.of()), null, authorities)); + + assertThatThrownBy( + () -> repository.saveContext(context, request, new MockHttpServletResponse())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authorities"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfigTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfigTest.java new file mode 100644 index 0000000..b35c4e6 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfigTest.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.session.MapSessionRepository; +import org.springframework.session.web.http.CookieSerializer; + +class RedisSessionWebConfigTest { + + private final WebApplicationContextRunner runner = + new WebApplicationContextRunner() + .withUserConfiguration(PropertiesConfig.class, RedisSessionWebConfig.class); + + @Test + void jwtModeCreatesNoSessionFilterOrCookieSerializer() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=jwt", + "ca-skeleton.security.issuer-uri=https://issuer.example") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(CookieSerializer.class); + assertThat(context).doesNotHaveBean("springSessionRepositoryFilter"); + }); + } + + @Test + void redisSessionModeWritesSecureHttpOnlySameSiteHostOnlyCookie() { + runner + .withBean( + MapSessionRepository.class, () -> new MapSessionRepository(new ConcurrentHashMap<>())) + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.security.session.cookie-name=APP_SESSION", + "ca-skeleton.security.session.secure=true", + "ca-skeleton.security.session.http-only=true", + "ca-skeleton.security.session.same-site=Strict", + "ca-skeleton.security.session.path=/") + .run( + context -> { + assertThat(context).hasNotFailed(); + CookieSerializer serializer = context.getBean(CookieSerializer.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + serializer.writeCookieValue( + new CookieSerializer.CookieValue(request, response, "opaque-session-id")); + + assertThat(response.getHeader("Set-Cookie")) + .contains("APP_SESSION=") + .contains("Path=/") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Strict") + .doesNotContain("Domain="); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(SecuritySettings.class) + static class PropertiesConfig {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityModeWebContractTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityModeWebContractTest.java new file mode 100644 index 0000000..66c76ef --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityModeWebContractTest.java @@ -0,0 +1,199 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.settings.CorsSettings; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import jakarta.servlet.Filter; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.session.SessionManagementFilter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import tools.jackson.databind.ObjectMapper; + +class SecurityModeWebContractTest { + + private final WebApplicationContextRunner runner = + new WebApplicationContextRunner() + .withUserConfiguration(PropertiesConfig.class, SecurityConfig.class) + .withBean(JwtToAuthenticatedPrincipalConverter.class) + .withBean(ObjectMapper.class, ObjectMapper::new) + .withBean( + JwtDecoder.class, + () -> + token -> { + throw new UnsupportedOperationException("decoder must remain unused"); + }); + + @Test + void redisSessionModeEnablesCsrfAndRotatesAnAuthenticatedSessionIdentifier() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.security.public-paths=/probe,/csrf", + "ca-skeleton.cors.enabled=false") + .run( + context -> { + MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class)); + try { + mvc.perform(post("/probe")).andExpect(status().isForbidden()); + var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN"); + assertThat(csrfCookie).isNotNull(); + mvc.perform( + post("/probe") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfCookie.getValue())) + .andExpect(status().isOk()); + + FilterChainProxy proxy = + context.getBean("springSecurityFilterChain", FilterChainProxy.class); + SessionManagementFilter sessionManagement = + proxy.getFilterChains().getFirst().getFilters().stream() + .filter(SessionManagementFilter.class::isInstance) + .map(SessionManagementFilter.class::cast) + .findFirst() + .orElseThrow(); + Object strategy = + ReflectionTestUtils.getField( + sessionManagement, "sessionAuthenticationStrategy"); + assertThat(strategy).isInstanceOf(CompositeSessionAuthenticationStrategy.class); + assertThat( + (java.util.List) + ReflectionTestUtils.getField(strategy, "delegateStrategies")) + .anyMatch(SessionFixationProtectionStrategy.class::isInstance); + } catch (Exception exception) { + throw new AssertionError("session security contract failed", exception); + } + }); + } + + @Test + void redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.security.public-paths=/login-test,/csrf", + "ca-skeleton.cors.enabled=false") + .run( + context -> { + MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class)); + try { + var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN"); + var login = + mvc.perform( + post("/login-test") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfCookie.getValue())) + .andExpect(status().isOk()) + .andReturn(); + MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); + assertThat(session).isNotNull(); + assertThat( + session.getAttribute( + PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE)) + .isInstanceOf(byte[].class); + assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull(); + + mvc.perform(get("/whoami").session(session)) + .andExpect(status().isOk()) + .andExpect(content().string("session-user")); + } catch (Exception exception) { + throw new AssertionError( + "primitive session security context round-trip failed", exception); + } + }); + } + + @Test + void jwtModeRemainsCsrfDisabledAndStateless() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=jwt", + "ca-skeleton.security.issuer-uri=https://issuer.example", + "ca-skeleton.security.public-paths=/probe", + "ca-skeleton.cors.enabled=false") + .run( + context -> { + MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class)); + try { + var result = mvc.perform(post("/probe")).andExpect(status().isOk()).andReturn(); + assertThat(result.getRequest().getSession(false)).isNull(); + } catch (Exception exception) { + throw new AssertionError("JWT security contract failed", exception); + } + }); + } + + private static MockMvc mvc(Filter springSecurityFilterChain) { + return MockMvcBuilders.standaloneSetup(new ProbeController()) + .addFilters(springSecurityFilterChain) + .build(); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties({SecuritySettings.class, CorsSettings.class}) + static class PropertiesConfig {} + + @RestController + static class ProbeController { + + @GetMapping("/probe") + String getProbe() { + return "ok"; + } + + @PostMapping("/probe") + String postProbe() { + return "ok"; + } + + @GetMapping("/csrf") + String csrf(HttpServletRequest request) { + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + return token.getToken(); + } + + @PostMapping("/login-test") + String loginForContract() { + SecurityContextHolder.getContext() + .setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + new AuthenticatedPrincipal( + "session-user", "session-user@example.test", java.util.Set.of("operator")), + null, + java.util.Set.of(new SimpleGrantedAuthority("ROLE_OPERATOR")))); + return "authenticated"; + } + + @GetMapping("/whoami") + String whoami() { + return ((AuthenticatedPrincipal) + SecurityContextHolder.getContext().getAuthentication().getPrincipal()) + .idpUserId(); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridgeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridgeTest.java new file mode 100644 index 0000000..e43e7f9 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridgeTest.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerMapping; + +class EdgeRateLimitTransportBridgeTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-07-29T01:00:00Z"), ZoneOffset.UTC); + private static final String DIGEST = "v7:" + "b".repeat(64); + private static final String EVALUATION_ID = "ev9:" + "C".repeat(22); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void sendsOnlyABoundedPseudonymousSubjectAndTransportBudgetToThePort() { + Capture capture = new Capture(); + RateLimitOutcome expected = + new RateLimitOutcome.Evaluated( + new RateLimitDecision( + true, + 100, + 99, + Duration.ZERO, + CLOCK.instant().plusSeconds(1), + "api-default", + "v3", + RateLimitDecision.DecisionSource.GLOBAL_REDIS, + RateLimitDecision.DecisionCertainty.CERTAIN)); + EdgeRateLimitTransportBridge bridge = + new EdgeRateLimitTransportBridge( + request -> { + capture.request = request; + return expected; + }, + subject -> { + capture.rawSubject = subject; + return new RateLimitSubjectDigest(DIGEST); + }, + new RateLimitKeyResolver(new RemoteAddrClientIpResolver()), + CLOCK, + "api-default", + Duration.ofMillis(750), + () -> EVALUATION_ID); + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal("raw-user-42", "raw@example.com", Set.of("user")); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", Set.of())); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs/123"); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}"); + request.addHeader("Idempotency-Key", "client-controlled-value"); + request.addHeader("X-Rate-Limit-Evaluation-Id", "ev1:" + "Z".repeat(22)); + + RateLimitOutcome actual = bridge.evaluate(request); + + assertThat(actual).isSameAs(expected); + assertThat(capture.rawSubject) + .isEqualTo( + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.PRINCIPAL, "raw-user-42", "GET /v1/worklogs/{id}")); + assertThat(capture.request.policyId()).isEqualTo("api-default"); + assertThat(capture.request.subjectDigest()).isEqualTo(DIGEST); + assertThat(capture.request.subjectDigest()) + .doesNotContain("raw-user-42") + .doesNotContain("raw@example.com"); + assertThat(capture.request.cost()).isEqualTo(1); + assertThat(capture.request.evaluationId()).isEqualTo(EVALUATION_ID); + assertThat(capture.request.evaluationId()) + .doesNotContain("client-controlled-value") + .doesNotContain("ZZZZ"); + assertThat(capture.request.callerDeadline()) + .isEqualTo(Instant.parse("2026-07-29T01:00:00.750Z")); + } + + private static final class Capture { + + private EdgeRateLimitSubject rawSubject; + private RateLimitRequest request; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettingsTest.java new file mode 100644 index 0000000..6dffb8e --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportSettingsTest.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class EdgeRateLimitTransportSettingsTest { + + @Test + void defaultsPolicyAndCallerBudgetWithoutSelectingALocalProvider() { + EdgeRateLimitTransportSettings settings = + new EdgeRateLimitTransportSettings(true, null, null, 0, null); + + assertThat(settings.enabled()).isTrue(); + assertThat(settings.defaultPolicyId()).isEqualTo("api-default"); + assertThat(settings.callerDeadlineBudget()).isEqualTo(Duration.ofSeconds(2)); + assertThat(settings.hashKeyVersion()).isEqualTo(1); + assertThat(settings.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY); + } + + @Test + void rejectsUnboundedPolicyAndDeadlineValues() { + assertThatThrownBy( + () -> + new EdgeRateLimitTransportSettings( + true, "INVALID POLICY", Duration.ofSeconds(1), 1, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("defaultPolicyId"); + assertThatThrownBy( + () -> + new EdgeRateLimitTransportSettings( + true, "api-default", Duration.ofSeconds(31), 1, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("callerDeadlineBudget"); + assertThatThrownBy( + () -> + new EdgeRateLimitTransportSettings( + true, "api-default", Duration.ofSeconds(1), 10_000, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hashKeyVersion"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java deleted file mode 100644 index 75bfe8d..0000000 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import org.junit.jupiter.api.Test; - -class FixedWindowRateLimiterTest { - - private static final Instant T0 = Instant.parse("2026-06-09T12:00:00Z"); - - @Test - void allowsUpToTheLimitThenRejectsWithinAWindow() { - FixedWindowRateLimiter limiter = - new FixedWindowRateLimiter(2, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC)); - - assertThat(limiter.decide("k").allowed()).isTrue(); - RateLimitDecision second = limiter.decide("k"); - assertThat(second.allowed()).isTrue(); - assertThat(second.remaining()).isZero(); - RateLimitDecision third = limiter.decide("k"); - assertThat(third.allowed()).isFalse(); - assertThat(third.remaining()).isZero(); - } - - @Test - void separateKeysHaveIndependentCounters() { - FixedWindowRateLimiter limiter = - new FixedWindowRateLimiter(1, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC)); - assertThat(limiter.decide("a").allowed()).isTrue(); - assertThat(limiter.decide("b").allowed()).isTrue(); - assertThat(limiter.decide("a").allowed()).isFalse(); - } - - @Test - void counterResetsWhenTheWindowRolls() { - MutableClock clock = new MutableClock(T0); - FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1, Duration.ofSeconds(1), clock); - - assertThat(limiter.decide("k").allowed()).isTrue(); - assertThat(limiter.decide("k").allowed()).isFalse(); - clock.advance(Duration.ofSeconds(1)); // next fixed window - assertThat(limiter.decide("k").allowed()).isTrue(); - } - - @Test - void resetInstantIsTheWindowEnd() { - FixedWindowRateLimiter limiter = - new FixedWindowRateLimiter(5, Duration.ofSeconds(60), Clock.fixed(T0, ZoneOffset.UTC)); - // T0 = 12:00:00 → 60s window starting at 12:00:00 ends at 12:01:00. - assertThat(limiter.decide("k").resetAt()).isEqualTo(Instant.parse("2026-06-09T12:01:00Z")); - } - - static final class MutableClock extends Clock { - private Instant instant; - - MutableClock(Instant start) { - this.instant = start; - } - - void advance(Duration d) { - instant = instant.plus(d); - } - - @Override - public Instant instant() { - return instant; - } - - @Override - public ZoneId getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(ZoneId zone) { - return this; - } - } -} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java index 482921f..67ec066 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java @@ -3,75 +3,176 @@ package dev.caskeleton.adapter.inbound.web.ratelimit; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.servlet.HandlerMapping; import tools.jackson.databind.ObjectMapper; class RateLimitInterceptorTest { private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-06-09T12:00:00Z"), ZoneOffset.UTC); + private static final String SUBJECT_DIGEST = "v1:" + "a".repeat(64); private final ObjectMapper objectMapper = new ObjectMapper(); - private RateLimitInterceptor interceptor(boolean enabled, int limit) { - FixedWindowRateLimiter limiter = - new FixedWindowRateLimiter(limit, Duration.ofSeconds(1), CLOCK); - return new RateLimitInterceptor( - enabled, - limiter, - new RateLimitKeyResolver(new RemoteAddrClientIpResolver()), - objectMapper, - 1); - } - - private MockHttpServletRequest request() { - MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs"); - req.setRemoteAddr("203.0.113.7"); - return req; - } - @Test - void allowedRequestPassesAndEmitsSignalingHeaders() throws Exception { - MockHttpServletResponse res = new MockHttpServletResponse(); + void allowedRequestPassesAndEmitsExistingSignalingHeaders() throws Exception { + RateLimitOutcome outcome = + evaluated(true, 5, 4, Duration.ZERO, Instant.parse("2026-06-09T12:00:01Z")); + MockHttpServletResponse response = new MockHttpServletResponse(); - boolean proceed = interceptor(true, 5).preHandle(request(), res, new Object()); + boolean proceed = interceptor(outcome).preHandle(request(), response, new Object()); assertThat(proceed).isTrue(); - assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5"); - assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4"); - assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z"); + assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5"); + assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4"); + assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z"); + assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull(); } @Test - void exceedingTheLimitRejectsWith429EnvelopeRetryAfterAndRetryableTrue() throws Exception { - RateLimitInterceptor interceptor = interceptor(true, 1); - // first request consumes the only slot - interceptor.preHandle(request(), new MockHttpServletResponse(), new Object()); + void deniedDecisionRejectsWith429AndUsesTheProviderRetryHint() throws Exception { + RateLimitOutcome outcome = + evaluated(false, 1, 0, Duration.ofMillis(1500), Instant.parse("2026-06-09T12:00:02Z")); + MockHttpServletResponse response = new MockHttpServletResponse(); - MockHttpServletResponse res = new MockHttpServletResponse(); - boolean proceed = interceptor.preHandle(request(), res, new Object()); + boolean proceed = interceptor(outcome).preHandle(request(), response, new Object()); assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(429); - assertThat(res.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1"); - assertThat(res.getContentAsString()) + assertThat(response.getStatus()).isEqualTo(429); + assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("2"); + assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("1"); + assertThat(response.getContentAsString()) .contains("\"RATE_LIMIT_EXCEEDED\"") .contains("\"RATE_LIMIT\"") .contains("\"retryable\":true"); } @Test - void disabledLimiterPassesWithoutTouchingHeaders() throws Exception { - MockHttpServletResponse res = new MockHttpServletResponse(); + void unavailableAndIndeterminateOutcomesMapTo503WithTheirOwnRetryHints() throws Exception { + MockHttpServletResponse unavailableResponse = new MockHttpServletResponse(); + MockHttpServletResponse indeterminateResponse = new MockHttpServletResponse(); - boolean proceed = interceptor(false, 1).preHandle(request(), res, new Object()); + boolean unavailableProceed = + interceptor( + new RateLimitOutcome.Unavailable( + "api-default", + Duration.ofMillis(100), + RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND)) + .preHandle(request(), unavailableResponse, new Object()); + boolean indeterminateProceed = + interceptor(new RateLimitOutcome.Indeterminate("api-default", Duration.ofMillis(2500))) + .preHandle(request(), indeterminateResponse, new Object()); + + assertThat(unavailableProceed).isFalse(); + assertThat(unavailableResponse.getStatus()).isEqualTo(503); + assertThat(unavailableResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1"); + assertThat(unavailableResponse.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull(); + assertThat(unavailableResponse.getContentAsString()) + .contains("\"RATE_LIMIT_UNAVAILABLE\"") + .contains("\"retryable\":true"); + assertThat(indeterminateProceed).isFalse(); + assertThat(indeterminateResponse.getStatus()).isEqualTo(503); + assertThat(indeterminateResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("3"); + } + + @Test + void incompatibleOutcomeMapsToNonRetryable503WithoutInventingARetryHint() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean proceed = + interceptor( + new RateLimitOutcome.Incompatible( + "api-default", RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE)) + .preHandle(request(), response, new Object()); + + assertThat(proceed).isFalse(); + assertThat(response.getStatus()).isEqualTo(503); + assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull(); + assertThat(response.getContentAsString()) + .contains("\"RATE_LIMIT_INCOMPATIBLE\"") + .contains("\"retryable\":false"); + } + + @Test + void disabledModeHasNoProviderPseudonymizerOrResolverSideEffects() throws Exception { + AtomicInteger calls = new AtomicInteger(); + EdgeRateLimitPort port = + request -> { + calls.incrementAndGet(); + throw new AssertionError("disabled interceptor must not call the provider"); + }; + EdgeSubjectPseudonymizer pseudonymizer = + subject -> { + calls.incrementAndGet(); + throw new AssertionError("disabled interceptor must not pseudonymize"); + }; + EdgeRateLimitTransportBridge unusedBridge = + new EdgeRateLimitTransportBridge( + port, + pseudonymizer, + new RateLimitKeyResolver( + request -> { + calls.incrementAndGet(); + return request.getRemoteAddr(); + }), + CLOCK, + "api-default", + Duration.ofSeconds(1), + () -> "ev1:" + "D".repeat(22)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean proceed = + RateLimitInterceptor.disabled(objectMapper).preHandle(request(), response, unusedBridge); assertThat(proceed).isTrue(); - assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull(); + assertThat(calls).hasValue(0); + assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull(); + } + + private RateLimitInterceptor interceptor(RateLimitOutcome outcome) { + EdgeRateLimitTransportBridge bridge = + new EdgeRateLimitTransportBridge( + request -> outcome, + subject -> new RateLimitSubjectDigest(SUBJECT_DIGEST), + new RateLimitKeyResolver(new RemoteAddrClientIpResolver()), + CLOCK, + "api-default", + Duration.ofSeconds(1), + () -> "ev1:" + "D".repeat(22)); + return RateLimitInterceptor.enabled(bridge, objectMapper); + } + + private MockHttpServletRequest request() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs"); + request.setRemoteAddr("203.0.113.7"); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs"); + return request; + } + + private static RateLimitOutcome evaluated( + boolean allowed, long limit, long remaining, Duration retryAfter, Instant resetAt) { + return new RateLimitOutcome.Evaluated( + new RateLimitDecision( + allowed, + limit, + remaining, + retryAfter, + resetAt, + "api-default", + "v1", + RateLimitDecision.DecisionSource.GLOBAL_REDIS, + RateLimitDecision.DecisionCertainty.CERTAIN)); } } diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java index 798da94..5b22af7 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java @@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.web.ratelimit; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject; import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -27,34 +28,42 @@ class RateLimitKeyResolverTest { } @Test - void unauthenticatedKeyIsIpPlusRouteTemplate() { + void unauthenticatedSubjectIsBoundedClientIpPlusRouteTemplate() { MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123"); req.setRemoteAddr("203.0.113.7"); req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}"); - assertThat(resolver.resolve(req)).isEqualTo("ip:203.0.113.7:GET /v1/worklogs/{id}"); + assertThat(resolver.resolve(req)) + .isEqualTo( + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.CLIENT_IP, "203.0.113.7", "GET /v1/worklogs/{id}")); } @Test - void unauthenticatedKeyFallsBackToUriWhenNoPattern() { + void unauthenticatedSubjectUsesABoundedFallbackWhenNoRouteTemplateExists() { MockHttpServletRequest req = new MockHttpServletRequest("POST", "/v1/worklogs"); req.setRemoteAddr("198.51.100.4"); - assertThat(resolver.resolve(req)).isEqualTo("ip:198.51.100.4:POST /v1/worklogs"); + assertThat(resolver.resolve(req).operationId()).isEqualTo("POST "); } @Test - void authenticatedUserKeyIsKeyedByPrincipal() { + void authenticatedUserSubjectIsPrincipalPlusOperation() { authenticateAs(new AuthenticatedPrincipal("user-42", "u@x.io", Set.of("user"))); - assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs"))) - .isEqualTo("user:user-42"); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs"); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs"); + + assertThat(resolver.resolve(request)) + .isEqualTo( + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.PRINCIPAL, "user-42", "GET /v1/worklogs")); } @Test - void servicePrincipalKeyIsKeyedByApiKeyId() { + void servicePrincipalSubjectUsesApiKeyKind() { authenticateAs(new AuthenticatedPrincipal("svc-7", "svc@x.io", Set.of("service"))); - assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs"))) - .isEqualTo("apikey:svc-7"); + assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")).kind()) + .isEqualTo(EdgeRateLimitSubject.Kind.API_KEY); } @Test @@ -63,7 +72,7 @@ class RateLimitKeyResolverTest { req.setRemoteAddr("10.0.0.1"); req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); - assertThat(resolver.resolve(req)).isEqualTo("ip:10.0.0.1:GET /v1/ping"); + assertThat(resolver.resolve(req).canonicalIdentity()).isEqualTo("10.0.0.1"); } @Test @@ -74,7 +83,7 @@ class RateLimitKeyResolverTest { req.setRemoteAddr("10.0.0.1"); req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); - assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:203.0.113.9:GET /v1/ping"); + assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("203.0.113.9"); } @Test @@ -85,6 +94,6 @@ class RateLimitKeyResolverTest { req.setRemoteAddr("198.51.100.4"); req.addHeader("X-Forwarded-For", " "); - assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:198.51.100.4:GET /v1/ping"); + assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("198.51.100.4"); } } diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfigTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfigTest.java new file mode 100644 index 0000000..15168b5 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfigTest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import java.time.Clock; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import tools.jackson.databind.ObjectMapper; + +class RateLimitWebConfigTest { + + @Test + void disabledCapabilityDoesNotResolveProviderPseudonymizerOrClock() { + ObjectProvider clockProvider = provider(); + ObjectProvider rateLimitPortProvider = provider(); + ObjectProvider pseudonymizerProvider = provider(); + + new RateLimitWebConfig( + new EdgeRateLimitTransportSettings(false, null, null, 0, null), + new ObjectMapper(), + clockProvider, + rateLimitPortProvider, + pseudonymizerProvider); + + verifyNoInteractions(clockProvider, rateLimitPortProvider, pseudonymizerProvider); + } + + @SuppressWarnings("unchecked") + private static ObjectProvider provider() { + return mock(ObjectProvider.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java deleted file mode 100644 index 12ac0fd..0000000 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.ratelimit; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import org.junit.jupiter.api.Test; - -class RateLimiterFactoryTest { - - private static final Clock CLOCK = - Clock.fixed(Instant.parse("2026-06-09T00:00:00Z"), ZoneOffset.UTC); - - @Test - void fixedWindowAlgorithmBuildsAFixedWindowLimiter() { - RateLimiter limiter = - RateLimiterFactory.create( - RateLimitAlgorithm.FIXED_WINDOW, 10, Duration.ofSeconds(1), CLOCK); - - assertThat(limiter).isInstanceOf(FixedWindowRateLimiter.class); - // returns the interface type so the interceptor never sees the concrete class - RateLimitDecision decision = limiter.decide("k"); - assertThat(decision.allowed()).isTrue(); - assertThat(decision.limit()).isEqualTo(10); - } -} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGeneratorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGeneratorTest.java new file mode 100644 index 0000000..e01a2ce --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/SecureRandomRateLimitEvaluationIdGeneratorTest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.SecureRandom; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class SecureRandomRateLimitEvaluationIdGeneratorTest { + + @Test + void generatesVersionedBoundedServerSideIdsWithCryptographicRandomness() { + RateLimitEvaluationIdGenerator generator = + new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1); + Set generated = new HashSet<>(); + + for (int index = 0; index < 100; index++) { + generated.add(generator.generate()); + } + + assertThat(generated).hasSize(100).allMatch(value -> value.matches("ev1:[A-Za-z0-9_-]{22}")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizerTest.java new file mode 100644 index 0000000..6ec469f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizerTest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject; +import org.junit.jupiter.api.Test; + +class VersionedEdgeSubjectPseudonymizerTest { + + @Test + void lengthFramesEveryDimensionBeforeDelegatingAndVersionsTheDigest() { + StringBuilder delegatedInput = new StringBuilder(); + VersionedEdgeSubjectPseudonymizer pseudonymizer = + new VersionedEdgeSubjectPseudonymizer( + raw -> { + delegatedInput.append(raw); + return "c".repeat(64); + }, + 3); + + assertThat( + pseudonymizer + .pseudonymize( + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.CLIENT_IP, + "203.0.113.7", + "GET /v1/worklogs/{id}")) + .value()) + .isEqualTo("v3:" + "c".repeat(64)); + assertThat(delegatedInput).hasToString("9:CLIENT_IP|11:203.0.113.7|21:GET /v1/worklogs/{id}"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java deleted file mode 100644 index 263247b..0000000 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.caskeleton.adapter.inbound.web.settings; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm; -import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode; -import java.time.Duration; -import org.junit.jupiter.api.Test; - -class RateLimitSettingsTest { - - @Test - void bindsSuppliedValues() { - RateLimitSettings props = - new RateLimitSettings( - true, - 250, - Duration.ofSeconds(5), - RateLimitAlgorithm.FIXED_WINDOW, - RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED); - assertThat(props.enabled()).isTrue(); - assertThat(props.limit()).isEqualTo(250); - assertThat(props.window()).isEqualTo(Duration.ofSeconds(5)); - assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW); - assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED); - } - - @Test - void defaultsAbsentOrInvalidLimitWindowAndAlgorithm() { - RateLimitSettings props = new RateLimitSettings(false, null, null, null, null); - assertThat(props.limit()).isEqualTo(100); - assertThat(props.window()).isEqualTo(Duration.ofSeconds(1)); - assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW); - assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY); - } - - @Test - void rejectsNonPositiveLimitAndWindowWithSafeDefaults() { - RateLimitSettings props = new RateLimitSettings(true, 0, Duration.ZERO, null, null); - assertThat(props.limit()).isEqualTo(100); - assertThat(props.window()).isEqualTo(Duration.ofSeconds(1)); - } -} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java index ab2b49c..0b10a1b 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java @@ -68,6 +68,27 @@ class SecuritySettingsTest { assertThat(settings.publicPaths()).isUnmodifiable(); } + @Test + void redisSessionModeDoesNotRequireJwtAndBindsSecureHostOnlyCookiePolicy() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.security.session.cookie-name=APP_SESSION", + "ca-skeleton.security.session.secure=true", + "ca-skeleton.security.session.http-only=true", + "ca-skeleton.security.session.same-site=Strict") + .run( + context -> { + assertThat(context).hasNotFailed(); + SecuritySettings settings = context.getBean(SecuritySettings.class); + assertThat(settings.authMode()) + .isEqualTo(SecuritySettings.AuthenticationMode.REDIS_SESSION); + assertThat(settings.issuerUri()).isEmpty(); + assertThat(settings.session().cookieName()).isEqualTo("APP_SESSION"); + assertThat(settings.session().sameSite()).isEqualTo("Strict"); + }); + } + @Configuration @EnableConfigurationProperties(SecuritySettings.class) static class EnableProperties {} diff --git a/src/adapter/outbound/cache-redis/CLAUDE.md b/src/adapter/outbound/cache-redis/CLAUDE.md index 1e341b8..1142bce 100644 --- a/src/adapter/outbound/cache-redis/CLAUDE.md +++ b/src/adapter/outbound/cache-redis/CLAUDE.md @@ -15,6 +15,10 @@ Package root: `dev.caskeleton.adapter.outbound.cache`. - Implement semantic cache ports from `application-core` without exposing Redis concepts to core. - Own canonical physical keys, digesting, codec/envelope, program catalog, typed Redis atomic facades, runtime client adaptation, and capability-specific failure semantics. +- Implement absolute soft/hard expiry and deterministic bounded jitter behind the semantic cache + port; cache-aside/source protection policy remains framework-free in `application-core`. +- Implement the provider-neutral `EdgeRateLimitPort` with dedicated coordination Redis settings, + connection/admission, private keys and versioned atomic programs. - Keep the legacy cache router isolated while consumers migrate to semantic ports. - Reuse `adapter:outbound:support` for shared outbound concerns. @@ -24,10 +28,14 @@ Package root: `dev.caskeleton.adapter.outbound.cache`. `src/config/architecture/modules.json` entry. - No inbound transport, persistence entity/repository, bootstrap, or sample dependency. - Cache adapters do not decide business freshness, entitlement, or domain fallback rules. +- Physical Redis TTL must equal encoded hard expiry; future/corrupt schema must never collapse into + an ordinary miss. - Application/domain code must not receive raw Redis keys, commands, Lua/Function names, SDK objects, topology, or connection types. - Cache fail-open behavior must not be reused for session, idempotency, strict quota, lease, or fencing. +- Rate-limit composition must not reuse `app.cache.redis`, its connection, external client mode or + failure-open semantics; v1 is coordination-role and fail-closed only. - The standalone runtime/cache service lane is R1 evidence only. Sentinel/Cluster, TLS/ACL, persistence/restart, eviction and fault evidence are required separately for R2. diff --git a/src/adapter/outbound/cache-redis/README.md b/src/adapter/outbound/cache-redis/README.md index b0fd2c3..4196057 100644 --- a/src/adapter/outbound/cache-redis/README.md +++ b/src/adapter/outbound/cache-redis/README.md @@ -10,16 +10,157 @@ ## 현재 readiness -현재 standalone runtime과 semantic string cache는 R1이다. 모듈이 Lettuce connection lifecycle, +현재 checked-in readiness registry에는 `selected` card가 없으므로 Redis R2 release claim도 +없다. + +| Capability card | 현재 상태 | Promotion topology | +| --- | --- | --- | +| cache | `implemented-candidate` | standalone | +| edge rate limit | `implemented-candidate` | standalone | +| request-replay idempotency | `implemented-candidate` | standalone | +| cache refresh soft lease | `implemented-candidate` | standalone | +| session | `implemented-candidate` | standalone | +| fenced coordination | `not-implemented` | 없음 | + +`implemented-candidate`는 구현과 standalone/security/fault/compatibility evidence lane이 있다는 +뜻일 뿐 release selection이나 R2 qualification이 아니다. 현재 evidence는 Sentinel/Cluster, +k3s multi-node, topology failover, credential/certificate rotation 또는 R3를 증명하지 않는다. + +모듈은 Lettuce connection lifecycle, finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative -TTL, digest-protected bounded binary envelope, HMAC physical key, -invalidation, Lua `EVALSHA -> NOSCRIPT -> EVAL` 실행기를 제공한다. +TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary +envelope, HMAC physical key, +invalidation, closed-catalog +`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다. `app.cache.redis.client-mode=external`이면 프로젝트가 제공한 `RedisClient` 호환 경로를 사용하고 managed connection을 생성하지 않는다. -명시적으로 Redis 7.4 image를 띄워 실행하는 standalone lane이 실제 expiry와 -compare-and-delete Lua 실행을 검증하지만 Sentinel/Cluster, -TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 없으므로 R2가 아니다. +명시적으로 최소 지원 Redis 7.2 image를 띄워 실행하는 standalone lane이 실제 expiry, +compare-and-delete, cache `NX`, observation-token compare-and-replace, 세 rate-limit 프로그램, +각 프로그램의 exact-boundary/denial-no-consume, clock-regression state 불변, +token refill remainder와 malformed hash 분류를 검증한다. TLS named-user ACL에서 semantic +readiness의 `SCRIPT LOAD`/대표 명령 거부 증거는 있지만 Sentinel/Cluster, credential rotation, +restart/fault/eviction과 capability 전체의 운영 증거가 완성되지 않았으므로 R2가 아니다. + +## Role policy와 health 경계 + +Canonical role binding은 startup에 다음 정책을 fail-closed로 검증한다. + +- `CACHE`: `required=false`, `expected-eviction=allkeys-lfu|allkeys-lru` +- `COORDINATION`: `required=true`, `expected-eviction=noeviction` +- `SESSION`: `required=true`, `expected-eviction=noeviction` + +Redis 모듈은 바인딩된 role router만 사용해 capability-aware semantic probe를 수행한다. PING만으로 +ready를 선언하지 않는다. 모든 plan은 `ca-health:` namespace의 bounded opaque nonce key에 먼저 +5초 TTL을 부여하고 SET/GET round trip을 검증한다. 선택 capability별 대표 프로그램은 다음과 같다. + +- cache: `SET_IF_ABSENT_WITH_TTL` +- rate limit: `RATE_FIXED_WINDOW_V2` +- request-replay idempotency: `IDEMPOTENCY_CLAIM_V1` +- efficiency lease: `LEASE_ACQUIRE_V1` +- session: `SESSION_CREATE_V1` + +대표 프로그램은 catalog digest의 `EVALSHA` 경로와 bounded result schema를 검증한다. 별도의 +catalog-owned `semantic-capability-acl-v1` 프로그램은 Redis Lua API의 +`redis.acl_check_cmd`로 대표 프로그램의 exact ACL command/key surface와 `SCRIPT LOAD` 권한을 +비변경 방식으로 확인하고, `redis.REDIS_VERSION_NUM`으로 명시적인 Redis `>=7.2` policy gate를 +먼저 적용한다. 두 Lua API 상수/함수는 Redis 7.0부터 제공되지만 이 템플릿이 지원을 선언하는 +minimum은 7.2다. runtime identity에 허용해야 하는 probe key pattern은 +`~ca-health:*`다. probe는 성공/실패와 무관하게 best-effort cleanup을 수행하고, cleanup이 +거절돼도 모든 생성 key는 최대 5초 안에 만료된다. + +각 role은 startup에 full semantic qualification을 완료한 관측을 seed한다. 이후 health scrape는 +`APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL`(기본 5초) 동안 같은 관측을 재사용하고 role별 +single-flight로만 refresh한다. refresh follower는 기다리지 않으며 15초 기본 +`APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS` 안에서는 이전 관측과 `semanticObservedAt`, +`semanticAgeMillis`, `semanticStale=true`를 반환한다. 최대 staleness를 넘으면 +`SEMANTIC_OBSERVATION_STALE`로 fail closed한다. eligibility와 age는 monotonic ticker를 사용해 +wall-clock jump의 영향을 받지 않는다. + +연결 가능한 optional/required role의 ACL, Redis 7.2 minimum, program result/schema mismatch는 +모두 startup-fatal이다. 명확히 분류된 temporary connect/PING 실패만 optional CACHE를 dormant +route와 `COMMAND_UNAVAILABLE` 관측으로 시작하게 한다. health-triggered single-flight reconnect는 +후보에 PING과 full semantic qualification을 모두 수행한 뒤에만 기존 router를 swap하며, +required COORDINATION/SESSION과 auth/TLS/material/unknown failure는 계속 fail closed한다. + +Cluster에서 same-slot probe가 증명하는 범위는 해당 hash slot owner 한 노드뿐이다. 이 결과를 +cluster 전체 노드나 failover target의 version/ACL/program 호환성 증거로 확대 해석하면 안 되며, +운영 promotion 전 별도의 cluster-wide 외부 conformance가 필요하다. + +`shared-contract`의 framework-neutral snapshot은 role, 선택된 capability, availability, +sanitized reason, semantic observation metadata와 expected eviction만 제공한다. semantic success, read/write failure, +program ACL denial, program failure, admission saturation, recent command failure, closed route, +command unavailable, probe-in-progress, stale observation은 서로 다른 bounded reason이다. endpoint, deployment ID, key/value, +username, credential/trust reference와 server exception은 health detail에 노출하지 않는다. +Actuator 타입과 health-group 소유권은 `app-bootstrap`에 있다. CACHE 장애는 +`redisOptional`의 `state=DEGRADED` detail로만 나타나고 readiness를 내리지 않는다. +COORDINATION/SESSION 장애는 `redisRequired`를 `DOWN`으로 만들며, 어떤 Redis contributor도 +liveness에는 포함되지 않는다. role binding이 없으면 Redis client 생성과 Redis health +contributor 생성은 모두 0이다. + +이 runtime은 Redis `CONFIG GET/SET` 권한을 요구하거나 노출하지 않는다. 따라서 +`expected-eviction` 검증은 설정 의도에 대한 startup 검증이며 실제 server의 +`maxmemory-policy`를 증명하지 않는다. Snapshot/health detail은 이 한계를 +`CONFIGURED_EXPECTATION_ONLY`로, 외부 증거 상태를 +`externalEvictionAttestation=INCOMPLETE`로 명시한다. 운영 readiness를 더 강하게 만들려면 배포 +파이프라인의 외부 conformance job 또는 서명된 operator attestation으로 effective policy를 +검증해야 한다. semantic probe는 runtime `CONFIG`/`ACL` 조회나 변경 권한을 요구하지 않는다. + +## Distributed edge rate limit + +`shared-contract`의 `EdgeRateLimitPort` 뒤에서 fixed window, sliding-window counter, token bucket을 +정확히 하나의 versioned Lua 실행으로 평가한다. 세 프로그램은 Redis `TIME`을 한 번만 읽고, server +time, bounded clock-regression clamp, denial-no-consume, finite state TTL과 정확히 7개 필드인 응답 +계약을 공유한다. Redis `TYPE`의 status-table/string 차이를 정규화하고 malformed hash field는 +typed incompatibility로 닫는다. Token bucket은 refill division remainder를 상태로 보존해 호출 +빈도에 따라 quota가 달라지지 않는다. Sliding counter만 algorithm certainty가 approximate이고 +나머지는 certain이다. + +모든 closed program manifest의 `minimumRedisVersion`은 실제 minimum qualification lane과 같은 +7.2다. 더 낮은 Redis 버전은 별도 service lane이 추가되기 전까지 호환을 주장하지 않는다. + +## Redis-backed HTTP session + +`redis-session` readiness card는 standalone을 선택 topology로 하는 implemented candidate다. +`RedisVersionedSessionRepository`는 Spring Session의 저장소 경계만 구현하고, 쿠키·CSRF·session +fixation 정책은 inbound web이 소유한다. 실제 Redis 상태 변경은 manifest로 닫힌 6개 Lua 프로그램 +(create/inspect/save/touch/revoke/rotate)을 통해서만 수행한다. + +- raw session ID는 physical key에 들어가지 않고 versioned HMAC digest로 변환된다. +- idle timeout과 absolute lifetime을 동시에 적용하며 touch 쓰기는 설정된 interval로 제한한다. +- logout은 revision `0`의 adapter-private force-revoke를 사용한다. 하나의 Lua 실행에서 tombstone을 + 먼저 만들고 live hash를 삭제하므로 concurrent stale save가 세션을 부활시킬 수 없다. +- rotation은 old ID tombstone과 new ID 생성을 원자적으로 수행한다. old/new ID가 서로 다른 Cluster + slot이므로 현재 activation은 standalone만 허용하고 Cluster와 Sentinel을 startup에서 거부한다. +- 저장 payload는 N/N-1 version을 읽는 명시적 primitive allowlist envelope다. Java serialization과 + default typing을 쓰지 않는다. SHA-256 checksum은 우발적 손상 탐지용이며 authenticity 또는 공격자 + 변조 방지 보장이 아니다. +- timeout/response loss와 OOM은 성공이나 miss로 바꾸지 않고 unavailable/indeterminate로 닫는다. + 별도 요청에서 같은 operation ID를 자동 재사용해 reconcile하지 않으므로 운영자는 timeout 뒤에 + mutation 성공을 추정하면 안 된다. + +현재 저장소는 의도적으로 unindexed baseline이다. principal lookup, 사용자 전체 logout, +maximum-concurrent-session 제어는 제공하지 않는다. 이 기능이 필요한 프로젝트는 별도 bounded index와 +그 index의 원자성·복구 증거를 추가해야 한다. 현재 `card-redis-session` 레인은 같은 JVM 안의 서로 +독립적인 두 runtime/repository client가 하나의 standalone Redis를 공유할 때의 logout/stale-save +race, TLS+named ACL, partition+`noeviction` OOM/recovery, Redis 7.2/7.4 compatibility를 검증한다. +이는 multi-process/pod, rolling deployment, pod/network failure qualification이 아니다. + +아웃바운드 provider의 기본값은 +`ca-skeleton.capabilities.rate-limit.provider=disabled`다. `redis`로 선택하면 canonical +`COORDINATION` role, `failure-policy=fail-closed`, default policy와 secret reference가 모두 +필요하다. `app.rate-limit.enabled`는 HTTP transport enforcement만 제어하며 provider를 암묵적으로 +선택하거나 fallback을 만들지 않는다. 설정은 `app.cache.redis`를 fallback으로 사용하지 않고, +`distributedRateLimiter`라는 semantic port bean만 외부에 제공한다. Caller deadline이 canonical +Redis command timeout보다 짧으면 command를 보내지 않고 typed no-mutation outcome을 반환한다. + +Rate-limit physical key는 raw principal/IP/API key를 포함하지 않고 policy ID/revision/algorithm과 +이미 pseudonymized된 subject digest를 다시 HMAC한다. Unknown policy/state/program/reply, +pre-send admission failure, post-dispatch indeterminate failure와 unsafe Redis clock을 서로 다른 +outcome으로 보존하며 fail-open하지 않는다. 현재 standalone과 standalone TLS+named ACL의 +`implemented-candidate` evidence가 있다. Sentinel/Cluster, topology failover, +credential/certificate rotation, effective eviction/persistence attestation과 R3 증거는 없으며, +checked-in `selected` card가 없으므로 R2 release claim도 없다. ## Application cache contract @@ -36,6 +177,21 @@ TLS/ACL/credential rotation, restart/fault/eviction evidence, health/metrics가 TTL, jitter, codec, topology와 Redis SDK 타입은 이 port에 들어가지 않는다. 실제 product의 use case는 `CacheRegionPort`를 상속한 semantic subtype을 정의해야 한다. +`application-core`의 `CacheAsideExecutor`는 lookup/source/write 흐름을 공통화하고 다음을 +보장한다. + +- fresh/negative hit에서 source를 호출하지 않음; +- authoritative absence만 negative cache하고, miss refill은 `ONLY_IF_ABSENT`, stale/quarantine + refill은 `ONLY_IF_OBSERVED`로 기록; +- classified transient source failure에서만 hard expiry 전 stale fallback; +- local single-flight의 in-flight key/waiter bound와 abandoned-flight opportunistic cleanup; +- source bulkhead의 concurrency/admission/load deadline bound; +- unclassified exception과 interrupt/cancellation 보존. + +동기 source loader는 cooperative cancellation token을 확인해야 한다. 임의 source 코드를 +강제 종료하지 않으며, source가 token/deadline을 무시하면 bulkhead permit은 반환 시점까지 +점유된다. + ## Physical key `RedisKeyBuilder`만 다음 canonical shape를 만든다. @@ -50,24 +206,43 @@ version, 정확히 하나인 hash tag와 전체 UTF-8 byte bound를 검증한다 ## Atomic program foundation -`redis/program-set.json`은 세 Lua resource의 exact digest, signature, status, complexity와 timeout -certainty를 기록한다. `RedisAtomicPrimitives`는 compare-delete, compare-expire, -set-if-absent-with-TTL을 typed result로 노출하고 unknown status를 compatibility failure로 -처리한다. owner/value/operation/TTL은 Redis 호출 전에 제한된다. +`redis/*-program-set.json`과 `redis/program-set.json`은 cache/rate/idempotency/lease/session 및 +primitive Lua resource의 exact digest, signature, status, complexity와 timeout certainty를 +기록한다. `RedisAtomicPrimitives`는 compare-delete, +compare-expire, set-if-absent-with-TTL, replace-if-observed-with-TTL을 typed result로 노출하고 +unknown status를 compatibility failure로 처리한다. owner/value/observation/operation/TTL은 +Redis 호출 전에 제한된다. `redis/rate-program-set.json`은 structured rate-limit 프로그램의 +별도 digest/signature/status manifest다. Generic descriptor/catalog/executor와 typed primitive facade는 package-private collaborator다. Spring composition에는 raw Redis key/value/TTL을 받는 bean을 노출하지 않으며, 이후 semantic port adapter가 내부에서만 이 facade를 사용한다. -따라서 이 program set은 현재 internal R0 foundation이며, 실제 도메인 capability가 바로 소비할 -수 있는 production bean이나 application port가 아니다. +이 primitive facade 자체는 application에 노출되는 범용 Redis port가 아니다. Cache, rate limit, +idempotency, soft lease, session의 semantic provider만 closed catalog를 내부에서 소비하며, 이 +구조 자체가 release selection이나 R2 qualification을 뜻하지 않는다. `RedisLuaProgramExecutor`가 catalog source로 SHA-1 script identity를 계산하여 `EVALSHA`를 먼저 -호출하고 정확히 `NOSCRIPT`일 때만 compiled script를 `EVAL`한다. signature/argument bounds는 +호출하고 정확히 `NOSCRIPT`일 때만 catalog script를 `SCRIPT LOAD`한다. 반환 digest가 예상 identity와 +같은지 확인한 뒤 `EVALSHA`를 한 번만 재시도한다. signature/argument bounds는 client 호출 전에 다시 검증하고 descriptor catalog membership 및 반환 status membership을 -확인한다. unit lane은 강제 `NOSCRIPT` fallback을 검증하고 standalone real-service lane은 -compare-and-delete의 실제 atomic execution을 검증한다. +확인한다. unit lane은 강제 `NOSCRIPT` load/retry를 검증하고 standalone real-service lane은 +compare-and-delete, NX, bounded trailing-digest observed replace, concurrent-writer 보존을 실제 +Redis 7.2에서 검증한다. 같은 lane은 16MiB payload의 record/read/observed-replace와 +16MiB+1 사전 거부, mutation interrupt의 `INDETERMINATE` certainty와 interrupt flag 복원도 +실행한다. ## Managed runtime과 semantic region +Canonical activation은 +`ca-skeleton.capabilities.cache.bindings.default=redis`와 +`ca-skeleton.providers.redis.roles.cache`를 함께 요구한다. 전자는 semantic policy를, 후자는 +topology/TLS/ACL credential을 소유한다. Canonical region은 legacy `app.cache.redis.host`, +`password`, raw HMAC 값을 읽지 않고 CACHE role router와 +`RedisCredentialMaterialProvider`의 `secret://` reference만 사용한다. 같은 CACHE router가 L2 +command와 invalidation Pub/Sub을 함께 route하므로 topology rotation 때 새 subscription ACK가 +확인된 뒤 route가 교체된다. Canonical/legacy 동시 활성은 precedence를 추측하지 않고 startup에서 +거절한다. 현재 템플릿이 자동 조합하는 semantic region ID는 `default` 하나이며, 여러 product +region은 region registry/compiler가 추가되기 전까지 자동 생성한다고 주장하지 않는다. + `app.cache.redis.enabled=true`이고 `client-mode=managed`(기본값)이면 `LettuceRedisRuntime`이 단일 binary connection을 생성하고 종료 시 connection/client를 닫는다. 프로젝트가 `RedisClient`를 직접 제공하는 경우에는 `client-mode=external`을 명시해야 한다. 이 선택을 @@ -86,13 +261,24 @@ opaque source revision에는 대소 비교 의미가 없으므로 `ONLY_IF_SOURCE_REVISION_NEWER`는 임의 lexical comparison을 하지 않고 `NOT_RECORDED_PROVIDER_POLICY`를 반환한다. -Envelope는 source revision의 application invariant(1..128 characters)를 decode 때도 다시 -검사하고 canonical bytes의 SHA-256 digest가 맞지 않으면 corrupt schema result로 격리한다. +Envelope v2는 source revision, soft/hard absolute expiry와 payload를 digest로 보호한다. +`soft <= now < hard`는 stale, `hard <= now`는 expired miss다. Retired v1은 명시적 quarantine +후 reload 대상이고 future/corrupt envelope는 fail-fast다. Integrity digest를 version byte보다 +먼저 검사하며, digest가 맞더라도 현재 v2 구조가 잘못되면 corrupt로 분류한다. Stale/retired +lookup은 envelope digest를 opaque observation token으로 전달하고, cache-aside는 Lua에서 현재 +digest가 그 token과 같을 때만 새 envelope로 교체한다. 따라서 조회와 refresh 사이의 writer를 +삭제하거나 덮어쓰지 않는다. Source revision의 application invariant (1..128 characters)는 +decode 때도 다시 검사한다. + +`positive-soft-ttl`, 기존 `positive-ttl`(hard), `negative-ttl`, `ttl-jitter`, +`minimum-hard-ttl`은 startup에 immutable policy로 freeze된다. Jitter는 HMAC-derived physical +key와 policy revision으로 결정적이며 positive soft/hard에는 같은 factor를 적용한다. Redis +physical TTL은 envelope에 기록된 hard expiry와 같다. 추가 runtime setting은 `app.cache.redis.maximum-queued-commands=8`(범위 `1..4096`)과 -`app.cache.redis.maximum-in-flight-bytes=16777216`이다. command count와 retained -request/response byte budget을 모두 통과해야 Lettuce 호출을 시작하며, -`queue-count × (maximum-value-bytes + overhead)`도 byte bound 이하여야 한다. 이 관계는 +`app.cache.redis.maximum-in-flight-bytes=16777216`이다. 최대 readable envelope와 최대 command +byte를 별도로 계산하며, command count와 retained request/response byte budget을 모두 통과해야 +Lettuce 호출을 시작한다. `queue-count × maximum-command-bytes`도 byte bound 이하여야 한다. 이 관계는 timeout 완료 뒤 driver가 응답 decode 전까지 command args를 유지하는 경우도 유한하게 제한한다. timeout 직후에는 runtime admission population과 Lettuce retained population이 겹칠 수 있으므로 최악 상한은 대략 `maximum-in-flight-bytes + queue-count × per-command-bound`이고, 설정 검증은 @@ -104,6 +290,58 @@ Redis가 wire에 내보내는 bulk reply 자체를 `maximum-envelope-bytes + 1` Netty/codec에 먼저 할당하지 않는다. managed runtime을 활성화할 때 host가 누락되면 `localhost`로 암묵 fallback하지 않고 startup을 실패시킨다. +Generation/revision fence는 mass/per-key invalidation과 source-load race를 막는다. Distributed +refresh soft lease는 정상 시 중복 refresh를 줄이지만 TTL expiry/crash에서는 duplicate owner를 +허용하며, cache generation fence를 대체하는 correctness lock이 아니다. + +`app.cache.redis.l1.enabled=true`는 semantic string cache 앞에만 optional local L1을 붙인다. +L1은 maximum entries, maximum accounted weight, per-entry accounted weight, local TTL, generation +recheck interval과 invalidation subscriber queue를 모두 finite하게 검증한다. Local expiry는 Redis +envelope hard expiry보다 길어질 수 없다. Weight는 HMAC-derived local identity와 UTF-8 value, +entry/lookup metadata에 대한 고정 conservative allowance를 더한 admission/eviction accounting +proxy이며, JVM heap reservation이나 실제 object layout의 exact byte guarantee가 아니다. + +Invalidation Pub/Sub payload는 raw semantic key를 포함하지 않고 HMAC-authenticated bounded +message를 사용한다. Pub/Sub은 durable/exact invalidation 원장이 아니라 eviction hint다. Subscriber +disconnect나 queue overflow는 L1 전체를 flush하고, monotonic local invalidation epoch가 진행 중인 +generation probe와 refill admission을 무효화한다. 재연결 뒤 generation을 다시 읽기 전에는 L1 +admission을 허용하지 않는다. Hint 유실 시 mass invalidation은 periodic generation recheck, +per-key invalidation은 local TTL 안에서 Redis L2로 복귀한다. + +이 local tier는 cache-only internal type을 요구하므로 session, idempotency, strict rate-limit, +coordination provider에 적용할 수 없다. 해당 capability들은 local fail-open cache semantics를 +재사용하지 않는다. + +Refresh-ahead와 probabilistic early refresh는 아직 구현하지 않았다. 둘 다 correctness baseline이 +아니며, refresh-ahead는 명시적인 bounded hot-set registry/scheduler 없이 full keyspace scan으로 +대체하지 않는다. Probabilistic early refresh도 versioned probability descriptor와 deterministic +property test가 생기기 전에는 readiness guarantee로 광고하지 않는다. Cache card에는 standalone +TLS+named ACL과 bounded fault evidence가 있지만 Sentinel/Cluster Pub/Sub/failover, +credential/certificate rotation, persistence/restart, effective eviction attestation, +multi-process/pod L1/L2 coherence와 R3 qualification은 아직 없다. + +## Efficiency-only lease + +`ca-skeleton.capabilities.lease.provider=redis`를 명시한 경우에만 +`DistributedLeasePort`가 생성되며, canonical `COORDINATION` role router와 별도 HMAC secret +reference를 사용한다. 미선택 상태에서는 lease bean, secret resolution, native client와 thread +side effect가 모두 0이다. + +이 port의 guarantee는 오직 `EFFICIENCY_ONLY`다. acquire/inspect/renew/release는 같은 +owner token과 operation ID를 비교하고, response loss를 성공이나 실패로 추측하지 않고 +`INDETERMINATE`/`UNKNOWN`으로 유지한다. caller가 최초 send 전에 보관한 같은 attempt로 inspect +또는 acquire replay를 해야 ownership을 복구할 수 있다. Handle validity는 Redis가 보고한 remaining +TTL에서 command 왕복 monotonic elapsed와 drift budget을 차감하며, server expiry wall clock은 +telemetry 용도일 뿐이다. Watchdog는 worker와 registration 수, renewal cadence, application +deadline이 모두 유한하고 lease loss/unknown에서 작업 취소 callback을 한 번만 전달한다. + +`redisEfficiencyLeaseTest`는 pinned Redis 7.2와 다음/승인 버전에서 standalone concurrency, +TLS/ACL, partition/response uncertainty와 compatibility를 별도 qualification한다. 이 test는 +readiness card가 아니며 cache-refresh soft lease나 fenced coordination의 증거로 재사용되지 +않는다. Fencing token과 protected-resource stale-token rejection은 구현하지 않았으므로 +`redis-fenced-coordination` card는 계속 `not-implemented`다. 이 lease만으로 결제, 재고, +unique ID 또는 외부 장치 command 같은 correctness-sensitive write를 승인하면 안 된다. + ## Legacy path 기존 `CacheStoreRouter`, `RedisCacheStore`, `FailOpenCacheStore`는 호환성을 위해 남아 있다. 이 @@ -118,4 +356,5 @@ cd src ./gradlew :application-core:check :adapter:outbound:cache-redis:check --console=plain ./gradlew :adapter:outbound:cache-redis:redisServiceTest \ -Dredis.test.host=127.0.0.1 -Dredis.test.port=6379 --console=plain +./gradlew :adapter:outbound:cache-redis:redisEfficiencyLeaseTest --console=plain ``` diff --git a/src/adapter/outbound/cache-redis/build.gradle b/src/adapter/outbound/cache-redis/build.gradle index c3e5b45..98a5d71 100644 --- a/src/adapter/outbound/cache-redis/build.gradle +++ b/src/adapter/outbound/cache-redis/build.gradle @@ -4,12 +4,35 @@ dependencies { implementation project(':adapter:outbound:support') implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.session:spring-session-core' + implementation 'org.springframework.session:spring-session-data-redis' + implementation 'org.springframework.data:spring-data-redis' implementation 'io.lettuce:lettuce-core' + implementation 'io.micrometer:micrometer-core' implementation 'org.slf4j:slf4j-api' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } +sourceSets { + redisTest { + java.srcDir 'src/redisTest/java' + resources.srcDir 'src/redisTest/resources' + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +configurations { + redisTestImplementation.extendsFrom testImplementation + redisTestCompileOnly.extendsFrom testCompileOnly + redisTestRuntimeOnly.extendsFrom testRuntimeOnly +} + +dependencies { + redisTestImplementation 'org.testcontainers:testcontainers' +} + tasks.named('test') { useJUnitPlatform { excludeTags 'redis-service' @@ -32,3 +55,562 @@ tasks.register('redisServiceTest', Test) { } shouldRunAfter tasks.named('test') } + +def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') { + group = 'redis verification' + description = 'Fails readiness lanes when the redisTest evidence source set is empty.' + inputs.files(sourceSets.redisTest.allSource) + doLast { + Set javaSources = sourceSets.redisTest.java.files.findAll { + it.isFile() && it.name.endsWith('.java') + } + if (javaSources.isEmpty()) { + throw new GradleException( + 'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.') + } + File imageRegistry = rootProject.file('gradle/redis-test-images.properties') + if (!imageRegistry.isFile() || imageRegistry.length() == 0) { + throw new GradleException( + "Redis evidence image registry is missing or empty: ${imageRegistry}") + } + } +} + +def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata + +def redisSanitizedEvidenceFileNames = [ + 'manifest.json', + 'capability-card.json', + 'topology-fault-timeline.json' +] as Set +def redisSanitizedBundleSha256 = { File directory -> + java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256') + redisSanitizedEvidenceFileNames.toList().sort().each { String name -> + File file = new File(directory, name) + if (!file.isFile()) { + throw new GradleException( + "Redis sanitized bundle is missing ${name}: ${directory}") + } + byte[] nameBytes = name.getBytes('UTF-8') + byte[] contentBytes = file.bytes + digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array()) + digest.update(nameBytes) + digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array()) + digest.update(contentBytes) + } + digest.digest().encodeHex().toString() +} + +def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText -> + def evidenceTask = tasks.register(taskName, Test) { + group = 'redis verification' + description = descriptionText + dependsOn verifyRedisEvidenceSourcesPresent + testClassesDirs = sourceSets.redisTest.output.classesDirs + classpath = sourceSets.redisTest.runtimeClasspath + useJUnitPlatform { + includeTags tagExpression + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' + systemProperty 'redis.image.registry', + rootProject.file('gradle/redis-test-images.properties').absolutePath + List> sanitizedTimeline = [] + List declaredTags = tagExpression.split(/\s*&\s*/).toList() + String cardTag = declaredTags.find { it.startsWith('card-') } + String cardId = cardTag == null ? null : cardTag.substring('card-'.length()) + Set evidenceCategories = [ + 'standalone', + 'security', + 'sentinel', + 'cluster', + 'fault', + 'compatibility' + ] as Set + String evidenceCategory = declaredTags.find { + it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length())) + } + if (evidenceCategory != null) { + evidenceCategory = evidenceCategory.substring('redis-'.length()) + } + File evidenceDirectory = layout.buildDirectory.dir( + "redis-evidence/${taskName}").get().asFile + outputs.dir evidenceDirectory + afterTest { descriptor, result -> + String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}" + String identityDigest = java.security.MessageDigest.getInstance('SHA-256') + .digest(identity.getBytes('UTF-8')).encodeHex().toString() + sanitizedTimeline << [ + sequence : sanitizedTimeline.size() + 1, + testCaseIdSha256: identityDigest, + outcome : result.resultType.name(), + durationMillis : Math.max(0L, result.endTime - result.startTime) + ] + } + afterSuite { descriptor, result -> + if (descriptor.parent != null) { + return + } + evidenceDirectory.mkdirs() + Map card = cardId == null + ? null + : rootProject.ext.redisReadinessCards[cardId] as Map + Map digests = rootProject.ext.redisEvidenceDigests() + Map metadata = cardId == null + ? [ + providerIds : [], + roles : [], + programs : [], + keyVersions : [], + codecVersions : [], + guarantees : ['cross-cutting Redis evidence lane'], + nonGuarantees : ['does not qualify a capability card by itself'], + requiredSettings: [] + ] + : redisCapabilityMetadata[cardId] as Map + Map capabilityCard = [ + schemaVersion : 1, + cardId : cardId, + readiness : card?.state, + releaseQualification: 'NOT_CLAIMED', + promotionTopology : card?.selectedTopology, + sourceRevision : rootProject.ext.redisEvidenceSourceRevision, + sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState, + digests : digests, + minimumRedisVersion: '7.2', + providerIds : metadata.providerIds, + roles : metadata.roles, + programIds : metadata.programs, + keyVersions : metadata.keyVersions, + codecVersions : metadata.codecVersions, + guarantees : metadata.guarantees, + nonGuarantees : metadata.nonGuarantees, + requiredSettings : metadata.requiredSettings, + evidenceProfile : card?.requiredEvidence ?: [] + ] + File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json') + capabilityCardFile.setText( + groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(capabilityCard)) + '\n', + 'UTF-8') + Map timeline = [ + schemaVersion: 1, + taskName : taskName, + cardId : cardId, + topology : card?.selectedTopology, + evidence : evidenceCategory, + timelineKind : 'SANITIZED_TEST_RESULT', + actualEventTimeline: 'NOT_CAPTURED', + sourceRevision: rootProject.ext.redisEvidenceSourceRevision, + sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState, + digests : digests, + events : sanitizedTimeline + ] + File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json') + timelineFile.setText( + groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(timeline)) + '\n', + 'UTF-8') + Closure sha256 = { File file -> + java.security.MessageDigest.getInstance('SHA-256') + .digest(file.bytes).encodeHex().toString() + } + String outcome = result.resultType.name() == 'FAILURE' + ? 'failed' + : (result.testCount == 0 || result.skippedTestCount > 0 + ? 'skipped-with-reason' + : 'executed') + Map manifest = [ + schemaVersion : 1, + taskPath : path, + tagExpression : tagExpression, + cardId : cardId, + cardState : card?.state, + selectedTopology : card?.selectedTopology, + evidenceCategory : evidenceCategory, + outcome : outcome, + tests : [ + discovered: result.testCount, + executed : result.testCount - result.skippedTestCount, + passed : result.successfulTestCount, + failed : result.failedTestCount, + errors : 0, + skipped : result.skippedTestCount + ], + runtimeImageAttestation: 'NOT_CAPTURED', + actualEventTimeline: 'NOT_CAPTURED', + releaseQualification: 'NOT_CLAIMED', + sourceRevision : rootProject.ext.redisEvidenceSourceRevision, + sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState, + digests : digests, + companionSha256 : [ + capabilityCardSha256: sha256(capabilityCardFile), + timelineSha256 : sha256(timelineFile) + ] + ] + new File(evidenceDirectory, 'manifest.json').setText( + groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(manifest)) + '\n', + 'UTF-8') + } + doFirst { + [ + 'manifest.json', + 'capability-card.json', + 'topology-fault-timeline.json' + ].each { String generatedFile -> + new File(evidenceDirectory, generatedFile).delete() + } + layout.buildDirectory.file( + "redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete() + Set matchingSources = sourceSets.redisTest.java.files.findAll { File source -> + if (!source.isFile() || !source.name.endsWith('.java')) { + return false + } + String content = source.getText('UTF-8') + declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") } + } + if (matchingSources.isEmpty()) { + throw new GradleException( + "${taskName}: no redisTest source declares every required tag " + + "${declaredTags}; zero-evidence readiness must not pass.") + } + } + } + def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") { + group = 'redis verification' + description = "Validates the bounded sanitized artifact for ${taskName} before upload." + mustRunAfter evidenceTask + File sanitizerMarker = layout.buildDirectory.file( + "redis-evidence-sanitizer/${taskName}.sha256").get().asFile + doFirst { + sanitizerMarker.delete() + } + doLast { + File evidenceDirectory = layout.buildDirectory.dir( + "redis-evidence/${taskName}").get().asFile + if (!evidenceDirectory.isDirectory()) { + throw new GradleException( + "${taskName}: sanitized evidence directory was not generated") + } + Set allowedNames = redisSanitizedEvidenceFileNames + List files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: [] + if (files.collect { it.name } as Set != allowedNames || + evidenceDirectory.listFiles()?.any { it.isDirectory() }) { + throw new GradleException( + "${taskName}: sanitized evidence must contain exactly ${allowedNames}") + } + files.each { File file -> + if (file.length() > 1_048_576L || + java.nio.file.Files.isSymbolicLink(file.toPath()) || + !file.toPath().toRealPath().startsWith( + evidenceDirectory.toPath().toRealPath())) { + throw new GradleException( + "${taskName}: oversized, symlinked, or path-escaping artifact ${file}") + } + String text = file.getText('UTF-8') + Map forbidden = [ + pem : java.util.regex.Pattern.compile( + '(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'), + aclMaterial : java.util.regex.Pattern.compile( + "(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"), + uriUserInfo : java.util.regex.Pattern.compile( + '(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'), + secretReference : java.util.regex.Pattern.compile('(?i)secret://'), + rawMessageFields : java.util.regex.Pattern.compile( + '(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:') + ] + forbidden.each { String marker, java.util.regex.Pattern pattern -> + if (pattern.matcher(text).find()) { + throw new GradleException( + "${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material") + } + } + } + Map manifest = new groovy.json.JsonSlurper().parse( + new File(evidenceDirectory, 'manifest.json')) as Map + Map capability = new groovy.json.JsonSlurper().parse( + new File(evidenceDirectory, 'capability-card.json')) as Map + Map timeline = new groovy.json.JsonSlurper().parse( + new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map + Set manifestFields = [ + 'schemaVersion', + 'taskPath', + 'tagExpression', + 'cardId', + 'cardState', + 'selectedTopology', + 'evidenceCategory', + 'outcome', + 'tests', + 'runtimeImageAttestation', + 'actualEventTimeline', + 'releaseQualification', + 'sourceRevision', + 'sourceTreeState', + 'digests', + 'companionSha256' + ] as Set + Set capabilityFields = [ + 'schemaVersion', + 'cardId', + 'readiness', + 'releaseQualification', + 'promotionTopology', + 'sourceRevision', + 'sourceTreeState', + 'digests', + 'minimumRedisVersion', + 'providerIds', + 'roles', + 'programIds', + 'keyVersions', + 'codecVersions', + 'guarantees', + 'nonGuarantees', + 'requiredSettings', + 'evidenceProfile' + ] as Set + Set timelineFields = [ + 'schemaVersion', + 'taskName', + 'cardId', + 'topology', + 'evidence', + 'timelineKind', + 'actualEventTimeline', + 'sourceRevision', + 'sourceTreeState', + 'digests', + 'events' + ] as Set + if (manifest.keySet() != manifestFields || + capability.keySet() != capabilityFields || + timeline.keySet() != timelineFields || + (manifest.tests as Map).keySet() != [ + 'discovered', + 'executed', + 'passed', + 'failed', + 'errors', + 'skipped' + ] as Set || + (manifest.digests as Map).keySet() != [ + 'registrySha256', + 'imageRegistrySha256', + 'programSetSha256', + 'configurationSha256' + ] as Set || + (manifest.companionSha256 as Map).keySet() != [ + 'capabilityCardSha256', + 'timelineSha256' + ] as Set) { + throw new GradleException( + "${taskName}: sanitized evidence contains unknown or missing schema fields") + } + List> events = timeline.events as List> + if (events.size() > 10_000 || + events.withIndex().any { Map event, int index -> + event.keySet() != [ + 'sequence', + 'testCaseIdSha256', + 'outcome', + 'durationMillis' + ] as Set || + event.sequence != index + 1 || + !(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) || + !(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) || + !(event.durationMillis instanceof Number) || + (event.durationMillis as Number).longValue() < 0L + }) { + throw new GradleException( + "${taskName}: sanitized test summary contains malformed events") + } + if ((capability.requiredSettings as List).any { + !(it instanceof Map) || + (it as Map).keySet() != ['name', 'type', 'constraint'] as Set + }) { + throw new GradleException( + "${taskName}: capability card required settings are not a safe name/type/constraint projection") + } + Map tests = manifest.tests as Map + if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) || + events.size() != (tests.discovered as Number).intValue() || + events.count { it.outcome == 'SUCCESS' } != + (tests.passed as Number).intValue() || + events.count { it.outcome == 'FAILURE' } != + (tests.failed as Number).intValue() || + events.count { it.outcome == 'SKIPPED' } != + (tests.skipped as Number).intValue()) { + throw new GradleException( + "${taskName}: manifest outcome/counts do not match the sanitized test summary") + } + if (manifest.outcome == 'executed' && + ((tests.discovered as Number).longValue() <= 0L || + (tests.executed as Number).longValue() <= 0L || + (tests.passed as Number).longValue() <= 0L || + (tests.failed as Number).longValue() != 0L || + (tests.errors as Number).longValue() != 0L || + (tests.skipped as Number).longValue() != 0L)) { + throw new GradleException( + "${taskName}: executed evidence must be positive with zero failure/error/skip") + } + if (manifest.outcome == 'failed' && + (tests.failed as Number).longValue() <= 0L) { + throw new GradleException( + "${taskName}: failed evidence must retain a positive bounded failure count") + } + sanitizerMarker.parentFile.mkdirs() + String bundleSha = redisSanitizedBundleSha256(evidenceDirectory) + sanitizerMarker.setText("${bundleSha}\n", 'UTF-8') + if (manifest.outcome == 'skipped-with-reason') { + throw new GradleException( + "${taskName}: skipped or zero-executed evidence is not a passing readiness lane") + } + } + } + evidenceTask.configure { + finalizedBy sanitizerTask + } + evidenceTask +} + +tasks.register('verifyRedisEvidenceArtifactsForUpload') { + group = 'redis verification' + description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.' + doLast { + File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile + File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile + List evidenceDirectories = evidenceRoot.isDirectory() + ? evidenceRoot.listFiles().findAll { it.isDirectory() } + : [] + if (evidenceDirectories.isEmpty()) { + throw new GradleException( + 'No sanitized Redis evidence directory exists for upload') + } + Set evidenceTasks = evidenceDirectories.collect { it.name } as Set + Set markerTasks = markerRoot.isDirectory() + ? markerRoot.listFiles().findAll { + it.isFile() && it.name.endsWith('.sha256') + }.collect { + it.name.substring(0, it.name.length() - '.sha256'.length()) + } as Set + : [] as Set + if (evidenceTasks != markerTasks) { + throw new GradleException( + "Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}") + } + evidenceDirectories.each { File directory -> + Set files = directory.listFiles().findAll { it.isFile() } + .collect { it.name } as Set + if (files != redisSanitizedEvidenceFileNames) { + throw new GradleException( + "Redis upload directory ${directory.name} is outside the sanitized allowlist") + } + String bundleSha = redisSanitizedBundleSha256(directory) + String recordedSha = new File( + markerRoot, "${directory.name}.sha256").getText('UTF-8').trim() + if (recordedSha != bundleSha) { + throw new GradleException( + "Redis upload sanitizer bundle marker is stale for ${directory.name}") + } + } + } +} + +registerRedisEvidenceTask( + 'redisStandaloneTest', + 'redis-standalone', + 'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.') +registerRedisEvidenceTask( + 'redisSecurityTest', + 'redis-security', + 'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.') +registerRedisEvidenceTask( + 'redisSentinelTest', + 'redis-sentinel', + 'Runs the explicit Redis Sentinel topology evidence lane.') +registerRedisEvidenceTask( + 'redisClusterTest', + 'redis-cluster', + 'Runs the explicit Redis Cluster topology evidence lane.') +registerRedisEvidenceTask( + 'redisFaultTest', + 'redis-fault', + 'Runs bounded Redis outage, response-loss, memory, and recovery evidence.') +registerRedisEvidenceTask( + 'redisCompatibilityTest', + 'redis-compatibility', + 'Runs pinned minimum/next/approved Redis compatibility evidence.') +registerRedisEvidenceTask( + 'redisEfficiencyLeaseTest', + 'redis-efficiency-lease', + 'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.') + +def redisCardTags = [ + redisCacheCapabilityTest : 'card-redis-cache', + redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit', + redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency', + redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease', + redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination', + redisSessionCapabilityTest : 'card-redis-session' +] +redisCardTags.each { String taskName, String cardTag -> + registerRedisEvidenceTask( + taskName, + cardTag, + "Runs all real-service evidence owned by Redis capability card ${cardTag}.") +} + +def redisEvidenceTags = [ + Standalone : 'redis-standalone', + Security : 'redis-security', + Sentinel : 'redis-sentinel', + Cluster : 'redis-cluster', + Fault : 'redis-fault', + Compatibility: 'redis-compatibility' +] +def redisCardTaskStems = [ + Cache : 'card-redis-cache', + RateLimit : 'card-redis-edge-rate-limit', + Idempotency : 'card-redis-request-replay-idempotency', + SoftLease : 'card-redis-cache-refresh-soft-lease', + FencedCoordination: 'card-redis-fenced-coordination', + Session : 'card-redis-session' +] +redisCardTaskStems.each { String cardStem, String cardTag -> + redisEvidenceTags.each { String evidenceStem, String evidenceTag -> + registerRedisEvidenceTask( + "redis${cardStem}${evidenceStem}EvidenceTest", + "${cardTag} & ${evidenceTag}", + "Runs ${evidenceTag} evidence owned only by ${cardTag}.") + } +} + +tasks.named('check') { + dependsOn tasks.named('redisStandaloneTest') +} + +def redisLabContractDirectory = rootProject.file('../infra/redis-lab') +def redisLabContractTest = tasks.register('redisLabContractTest', Exec) { + group = 'verification' + description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.' + workingDir rootProject.projectDir + executable 'bash' + args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath + inputs.files( + new File(redisLabContractDirectory, 'versions.env'), + new File(redisLabContractDirectory, 'bin/redis-lab'), + new File(redisLabContractDirectory, 'cloud-init/node.yaml'), + new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'), + fileTree(new File(redisLabContractDirectory, 'test/fixtures')) { + include '**/*' + }, + new File(redisLabContractDirectory, 'test/redis-lab-contract.sh')) + outputs.upToDateWhen { false } +} + +tasks.named('check') { + dependsOn redisLabContractTest +} diff --git a/src/adapter/outbound/cache-redis/gradle.lockfile b/src/adapter/outbound/cache-redis/gradle.lockfile index 9e862f7..c328037 100644 --- a/src/adapter/outbound/cache-redis/gradle.lockfile +++ b/src/adapter/outbound/cache-redis/gradle.lockfile @@ -1,165 +1,186 @@ # 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=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=redisTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=redisTestCompileClasspath,redisTestRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=redisTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,redisTestCompileClasspath,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=redisTestCompileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=redisTestCompileClasspath,redisTestRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.7.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=redisTestCompileClasspath,redisTestRuntimeClasspath +net.minidev:accessors-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-compress:1.28.0=redisTestCompileClasspath,redisTestRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle 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=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=redisTestCompileClasspath,testCompileClasspath +org.assertj:assertj-core:3.27.6=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,redisTestAnnotationProcessor,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=redisTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=redisTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=redisTestRuntimeClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=redisTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=redisTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=redisTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=redisTestCompileClasspath,testCompileClasspath 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=testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.7.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,redisTestAnnotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=redisTestCompileClasspath,redisTestRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-oxm:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,redisTestCompileClasspath,redisTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=redisTestCompileClasspath,redisTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorker.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorker.java new file mode 100644 index 0000000..185320d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorker.java @@ -0,0 +1,231 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.LongSupplier; + +/** One daemon worker with storage bounded by the finite active Redis role count. */ +final class BoundedRedisSentinelRefreshWorker implements RedisSentinelRefreshWorker { + + private final Object monitor = new Object(); + private final int capacity; + private final ArrayDeque immediateTasks; + private final List recurringTasks; + private final Thread worker; + private final LongSupplier nanoTime; + private boolean closed; + private boolean preferDueRecurring; + + BoundedRedisSentinelRefreshWorker(int capacity, String threadName) { + this(capacity, threadName, System::nanoTime); + } + + BoundedRedisSentinelRefreshWorker(int capacity, String threadName, LongSupplier nanoTime) { + if (capacity < 1) { + throw new IllegalArgumentException("Redis Sentinel worker capacity must be positive"); + } + this.capacity = capacity; + this.immediateTasks = new ArrayDeque<>(capacity); + this.recurringTasks = new ArrayList<>(capacity); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null"); + this.worker = + Thread.ofPlatform().daemon(true).name(requireText(threadName)).unstarted(this::runWorker); + this.worker.start(); + } + + @Override + public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) { + Objects.requireNonNull(task, "task must be non-null"); + long delayNanos = positiveNanos(delay); + RecurringTask recurring = + new RecurringTask(task, delayNanos, nanoTime.getAsLong() + delayNanos); + synchronized (monitor) { + ensureOpen(); + if (recurringTasks.size() >= capacity) { + throw new IllegalStateException("Redis Sentinel recurring task capacity is exhausted"); + } + recurringTasks.add(recurring); + monitor.notifyAll(); + } + return () -> cancel(recurring); + } + + @Override + public boolean execute(Runnable task) { + Objects.requireNonNull(task, "task must be non-null"); + synchronized (monitor) { + if (closed || immediateTasks.size() >= capacity) { + return false; + } + immediateTasks.addLast(task); + monitor.notifyAll(); + return true; + } + } + + @Override + public void shutdown(Duration timeout) { + long timeoutNanos = positiveNanos(timeout); + synchronized (monitor) { + if (!closed) { + closed = true; + recurringTasks.forEach(task -> task.cancelled = true); + recurringTasks.clear(); + immediateTasks.clear(); + monitor.notifyAll(); + } + } + worker.interrupt(); + if (Thread.currentThread() == worker) { + return; + } + try { + long millis = Math.max(1, Math.min(Long.MAX_VALUE, timeoutNanos / 1_000_000L)); + worker.join(millis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + + private void runWorker() { + while (true) { + Work work; + try { + work = awaitWork(); + } catch (InterruptedException interrupted) { + if (isClosed()) { + Thread.currentThread().interrupt(); + return; + } + continue; + } + if (work == null) { + return; + } + try { + work.task.run(); + } catch (RuntimeException ignored) { + // Refresh failures are deliberately contained and rendered only through sanitized health. + } finally { + if (work.recurring != null) { + reschedule(work.recurring); + } + } + } + } + + private Work awaitWork() throws InterruptedException { + synchronized (monitor) { + while (!closed) { + if (!preferDueRecurring) { + Runnable immediate = immediateTasks.pollFirst(); + if (immediate != null) { + preferDueRecurring = true; + return new Work(immediate, null); + } + } + long now = nanoTime.getAsLong(); + RecurringTask due = null; + long waitNanos = Long.MAX_VALUE; + for (RecurringTask task : recurringTasks) { + if (task.cancelled || task.running) { + continue; + } + long remaining = task.nextRunNanos - now; + if (remaining <= 0) { + due = task; + break; + } + waitNanos = Math.min(waitNanos, remaining); + } + if (due != null) { + due.running = true; + preferDueRecurring = false; + return new Work(due.task, due); + } + Runnable immediate = immediateTasks.pollFirst(); + if (immediate != null) { + preferDueRecurring = true; + return new Work(immediate, null); + } + if (waitNanos == Long.MAX_VALUE) { + monitor.wait(); + } else { + long millis = waitNanos / 1_000_000L; + int nanos = (int) (waitNanos % 1_000_000L); + monitor.wait(millis, nanos); + } + } + return null; + } + } + + private void reschedule(RecurringTask task) { + synchronized (monitor) { + task.running = false; + if (!closed && !task.cancelled) { + task.nextRunNanos = nanoTime.getAsLong() + task.delayNanos; + } + monitor.notifyAll(); + } + } + + private void cancel(RecurringTask task) { + synchronized (monitor) { + task.cancelled = true; + recurringTasks.remove(task); + monitor.notifyAll(); + } + } + + private boolean isClosed() { + synchronized (monitor) { + return closed; + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Redis Sentinel refresh worker is closed"); + } + } + + private static long positiveNanos(Duration duration) { + Objects.requireNonNull(duration, "duration must be non-null"); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException("Redis Sentinel worker duration must be positive"); + } + try { + return duration.toNanos(); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + + private static String requireText(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Redis Sentinel worker name must be non-blank"); + } + return value.trim(); + } + + private record Work(Runnable task, RecurringTask recurring) {} + + private static final class RecurringTask { + + private final Runnable task; + private final long delayNanos; + private long nextRunNanos; + private boolean running; + private boolean cancelled; + + private RecurringTask(Runnable task, long delayNanos, long nextRunNanos) { + this.task = task; + this.delayNanos = delayNanos; + this.nextRunNanos = nextRunNanos; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisCacheInvalidationSubscription.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisCacheInvalidationSubscription.java new file mode 100644 index 0000000..2ea7016 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisCacheInvalidationSubscription.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import io.lettuce.core.RedisChannelHandler; +import io.lettuce.core.RedisConnectionStateListener; +import io.lettuce.core.pubsub.RedisPubSubAdapter; +import io.lettuce.core.pubsub.StatefulRedisPubSubConnection; +import java.net.SocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Managed standalone Redis Pub/Sub listener for best-effort cache invalidation hints. */ +final class LettuceRedisCacheInvalidationSubscription implements AutoCloseable { + + private final StatefulRedisPubSubConnection connection; + private final RedisCacheInvalidationSubscriber subscriber; + private final AtomicBoolean closed = new AtomicBoolean(); + + private LettuceRedisCacheInvalidationSubscription( + StatefulRedisPubSubConnection connection, + RedisCacheInvalidationSubscriber subscriber) { + this.connection = connection; + this.subscriber = subscriber; + } + + static LettuceRedisCacheInvalidationSubscription subscribe( + LettuceRedisRuntime runtime, + String channel, + RedisCacheInvalidationMessage.Codec codec, + RedisCacheInvalidationSubscriber subscriber) { + Objects.requireNonNull(runtime, "runtime must be non-null"); + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(codec, "codec must be non-null"); + Objects.requireNonNull(subscriber, "subscriber must be non-null"); + byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII); + StatefulRedisPubSubConnection connection = + runtime.openInvalidationSubscription(); + connection.addListener( + new RedisPubSubAdapter<>() { + @Override + public void message(byte[] actualChannel, byte[] message) { + if (!Arrays.equals(channelBytes, actualChannel) || message == null) { + return; + } + codec + .decode(new String(message, StandardCharsets.US_ASCII)) + .ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage); + } + }); + connection.addListener( + new RedisConnectionStateListener() { + @Override + public void onRedisConnected( + RedisChannelHandler connection, SocketAddress remoteAddress) { + // A preceding disconnect already forced L1 flush and generation recheck. + } + + @Override + public void onRedisDisconnected(RedisChannelHandler connection) { + subscriber.onDisconnected(); + } + }); + try { + connection.sync().subscribe(channelBytes); + return new LettuceRedisCacheInvalidationSubscription(connection, subscriber); + } catch (RuntimeException exception) { + connection.close(); + throw exception; + } + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + try { + connection.close(); + } finally { + subscriber.onDisconnected(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactory.java new file mode 100644 index 0000000..11ea24b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactory.java @@ -0,0 +1,228 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import io.lettuce.core.AbstractRedisClient; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.ConnectionFuture; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulConnection; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.cluster.ClusterClientOptions; +import io.lettuce.core.cluster.RedisClusterClient; +import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; +import io.lettuce.core.codec.ByteArrayCodec; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Opens, probes, and owns topology-native Lettuce clients and connections. */ +final class LettuceRedisNativeClientFactory implements RedisNativeClientFactory { + + interface LifecycleObserver { + + LifecycleObserver NOOP = new LifecycleObserver() {}; + + default void clientCreated() {} + + default void connectionClosed() {} + + default void clientClosed() {} + } + + private final LifecycleObserver observer; + + LettuceRedisNativeClientFactory() { + this(LifecycleObserver.NOOP); + } + + LettuceRedisNativeClientFactory(LifecycleObserver observer) { + this.observer = Objects.requireNonNull(observer, "observer must be non-null"); + } + + @Override + public RedisNativeClientHandle openStandalone( + RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) { + Objects.requireNonNull(uri, "uri must be non-null"); + Objects.requireNonNull(options, "options must be non-null"); + Objects.requireNonNull(settings, "settings must be non-null"); + RedisClient client = RedisClient.create(uri); + observer.clientCreated(); + StatefulRedisConnection connection = null; + try { + client.setOptions(options); + long deadline = deadline(settings.overallTimeout()); + ConnectionFuture> connect = + client.connectAsync(ByteArrayCodec.INSTANCE, uri); + connection = + await( + connect, + boundedByRemaining(settings.acquireTimeout(), deadline), + "Redis standalone connect"); + connection.setTimeout(settings.commandTimeout()); + await( + connection.async().ping(), + boundedByRemaining(settings.commandTimeout(), deadline), + "Redis standalone probe"); + return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer); + } catch (RuntimeException exception) { + closeFailed(client, connection, settings.shutdownTimeout(), observer, List.of(uri)); + throw sanitizedConnectFailure(exception); + } + } + + @Override + public RedisNativeClientHandle openCluster( + List seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings) { + List uris = + List.copyOf(Objects.requireNonNull(seedUris, "seedUris must be non-null")); + Objects.requireNonNull(options, "options must be non-null"); + Objects.requireNonNull(settings, "settings must be non-null"); + RedisClusterClient client = RedisClusterClient.create(uris); + observer.clientCreated(); + StatefulRedisClusterConnection connection = null; + try { + client.setOptions(options); + long deadline = deadline(settings.overallTimeout()); + java.util.concurrent.CompletableFuture> + connect = client.connectAsync(ByteArrayCodec.INSTANCE); + connection = + await( + connect, + boundedByRemaining(settings.acquireTimeout(), deadline), + "Redis Cluster connect"); + connection.setTimeout(settings.commandTimeout()); + await( + connection.async().ping(), + boundedByRemaining(settings.commandTimeout(), deadline), + "Redis Cluster probe"); + return new LettuceHandle(client, connection, settings.shutdownTimeout(), observer); + } catch (RuntimeException exception) { + closeFailed(client, connection, settings.shutdownTimeout(), observer, uris); + throw sanitizedConnectFailure(exception); + } + } + + private static long deadline(Duration overallTimeout) { + long timeoutNanos = overallTimeout.toNanos(); + long now = System.nanoTime(); + return now > Long.MAX_VALUE - timeoutNanos ? Long.MAX_VALUE : now + timeoutNanos; + } + + private static Duration boundedByRemaining(Duration operationTimeout, long deadline) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + throw new IllegalStateException("Redis overall connect deadline expired"); + } + Duration remainingDuration = Duration.ofNanos(remaining); + return operationTimeout.compareTo(remainingDuration) < 0 ? operationTimeout : remainingDuration; + } + + private static T await( + java.util.concurrent.Future future, Duration timeout, String operation) { + try { + return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS); + } catch (InterruptedException exception) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new IllegalStateException(operation + " was interrupted"); + } catch (TimeoutException exception) { + future.cancel(true); + throw new IllegalStateException(operation + " exceeded its bounded timeout"); + } catch (ExecutionException exception) { + throw new IllegalStateException(operation + " failed"); + } + } + + private static IllegalStateException sanitizedConnectFailure(RuntimeException ignored) { + return new IllegalStateException("Redis connect or probe failed within its bounded deadline"); + } + + private static void closeFailed( + AbstractRedisClient client, + StatefulConnection connection, + Duration shutdownTimeout, + LifecycleObserver observer, + List uris) { + try { + closeConnection(connection, observer); + } finally { + try { + client.shutdown(Duration.ZERO, shutdownTimeout); + } finally { + observer.clientClosed(); + uris.forEach(LettuceRedisNativeClientFactory::destroyCredentials); + } + } + } + + private static void closeConnection( + StatefulConnection connection, LifecycleObserver observer) { + if (connection == null) { + return; + } + try { + connection.close(); + } finally { + observer.connectionClosed(); + } + } + + private static void destroyCredentials(RedisURI uri) { + if (uri.getCredentialsProvider() instanceof javax.security.auth.Destroyable destroyable) { + try { + destroyable.destroy(); + } catch (javax.security.auth.DestroyFailedException ignored) { + // The adapter-owned providers do not throw; remain fail-safe for alternate implementations. + } + } + } + + private static final class LettuceHandle implements RedisNativeClientHandle { + + private final AbstractRedisClient client; + private final StatefulConnection connection; + private final Duration configuredShutdownTimeout; + private final LifecycleObserver observer; + private final AtomicBoolean closed = new AtomicBoolean(); + + private LettuceHandle( + AbstractRedisClient client, + StatefulConnection connection, + Duration configuredShutdownTimeout, + LifecycleObserver observer) { + this.client = client; + this.connection = connection; + this.configuredShutdownTimeout = configuredShutdownTimeout; + this.observer = observer; + } + + @Override + public Class nativeClientType() { + return client.getClass(); + } + + @Override + public void close(Duration timeout) { + Objects.requireNonNull(timeout, "timeout must be non-null"); + if (!timeout.equals(configuredShutdownTimeout)) { + throw new IllegalArgumentException("Redis shutdown timeout differs from runtime settings"); + } + if (closed.compareAndSet(false, true)) { + try { + closeConnection(connection, observer); + } finally { + try { + client.shutdown(Duration.ZERO, timeout); + } finally { + observer.clientClosed(); + } + } + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java index 3ac0faf..da15efb 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntime.java @@ -14,10 +14,12 @@ import io.lettuce.core.TimeoutOptions; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; import io.lettuce.core.codec.ByteArrayCodec; +import io.lettuce.core.pubsub.StatefulRedisPubSubConnection; import java.net.SocketAddress; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; @@ -26,19 +28,7 @@ import java.util.function.Supplier; final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, AutoCloseable { private static final String VALUE_TOO_LARGE_ERROR = "CA_VALUE_TOO_LARGE"; - private static final byte[] BOUNDED_GET_SCRIPT = - """ - local limit = tonumber(ARGV[1]) - local value = redis.call('GETRANGE', KEYS[1], 0, limit) - if #value > limit then - return redis.error_reply('CA_VALUE_TOO_LARGE') - end - if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then - return false - end - return value - """ - .getBytes(StandardCharsets.UTF_8); + private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation(); private final io.lettuce.core.RedisClient client; private final StatefulRedisConnection connection; @@ -54,17 +44,17 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut private LettuceRedisRuntime( io.lettuce.core.RedisClient client, StatefulRedisConnection connection, - RedisRuntimeSettings settings) { + RedisConnectionProfile settings) { this.client = client; this.connection = connection; this.commands = connection.sync(); - this.legacyTtl = settings.positiveTtl(); + this.legacyTtl = settings.legacyTtl(); this.shutdownTimeout = settings.commandTimeout(); this.commandAdmission = new RedisCommandAdmission( settings.maximumQueuedCommands(), settings.maximumInFlightBytes()); - this.maximumReadableValueBytes = settings.maximumValueBytes() + 1024 + 32; - this.maximumCommandBytes = settings.maximumValueBytes() + 2048; + this.maximumReadableValueBytes = settings.maximumReadableValueBytes(); + this.maximumCommandBytes = settings.maximumCommandBytes(); connection.addListener( new RedisConnectionStateListener() { @Override @@ -81,6 +71,14 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut } static LettuceRedisRuntime connect(RedisRuntimeSettings settings) { + return connect(RedisConnectionProfile.cache(settings)); + } + + static LettuceRedisRuntime connect(RedisLegacyStandaloneSettings settings) { + return connect(RedisConnectionProfile.rateLimit(settings)); + } + + private static LettuceRedisRuntime connect(RedisConnectionProfile settings) { RedisURI uri = redisUri(settings); io.lettuce.core.RedisClient client = io.lettuce.core.RedisClient.create(uri); client.setOptions(clientOptions(settings)); @@ -95,6 +93,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut } static RedisURI redisUri(RedisRuntimeSettings settings) { + return redisUri(RedisConnectionProfile.cache(settings)); + } + + private static RedisURI redisUri(RedisConnectionProfile settings) { RedisURI.Builder builder = RedisURI.Builder.redis(settings.host(), settings.port()) .withTimeout(settings.commandTimeout()); @@ -105,6 +107,10 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut } static ClientOptions clientOptions(RedisRuntimeSettings settings) { + return clientOptions(RedisConnectionProfile.cache(settings)); + } + + private static ClientOptions clientOptions(RedisConnectionProfile settings) { return ClientOptions.builder() .autoReconnect(true) .replayFilter(ignored -> true) @@ -116,7 +122,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut @Override public Optional read(String key) { - byte[] value = get(key.getBytes(StandardCharsets.UTF_8)); + byte[] value = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key))); return value == null ? Optional.empty() : Optional.of(new String(value, StandardCharsets.UTF_8)); @@ -124,84 +130,109 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut @Override public void write(String key, String value) { - set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), legacyTtl); + set( + RedisPhysicalKey.owned(new LegacyKeyMaterial(key)), + RedisBinaryValue.utf8(value), + legacyTtl); } @Override - public byte[] get(byte[] key) { - byte[] limit = Integer.toString(maximumReadableValueBytes).getBytes(StandardCharsets.US_ASCII); - try { - byte[] value = - execute( - false, - reservationBytes( - maximumReadableValueBytes, - List.of(BOUNDED_GET_SCRIPT), - List.of(key), - List.of(limit)), - () -> - commands.eval( - BOUNDED_GET_SCRIPT, - ScriptOutputType.VALUE, - new byte[][] {key.clone()}, - limit)); - return value == null ? null : value.clone(); - } catch (RedisCommandExecutionException exception) { - if (exception.getMessage() != null - && exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) { - throw new RedisValueTooLargeException(); - } - throw exception; - } + public byte[] get(RedisPhysicalKey key) { + RedisCatalogProgramInvocation invocation = + FOUNDATION_CATALOG.boundedGetInvocation(key, maximumReadableValueBytes); + byte[] value = RedisScriptRecovery.evalReadOnlyValue(this, invocation); + return value == null ? null : value.clone(); } @Override - public void set(byte[] key, byte[] value, Duration timeToLive) { + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + byte[] encodedValue = value.copyEncoded(); String result = execute( true, - reservationBytes(64, List.of(key, value)), + reservationBytes(64, List.of(encodedKey, encodedValue)), () -> - commands.set( - key.clone(), value.clone(), SetArgs.Builder.px(timeToLive.toMillis()))); + commands.set(encodedKey, encodedValue, SetArgs.Builder.px(timeToLive.toMillis()))); if (!"OK".equals(result)) { throw new IllegalStateException("Redis SET did not acknowledge the mutation"); } } @Override - public long delete(byte[] key) { - return execute(true, reservationBytes(32, List.of(key)), () -> commands.del(key.clone())); + public long delete(RedisPhysicalKey key) { + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + return execute(true, reservationBytes(32, List.of(encodedKey)), () -> commands.del(encodedKey)); } @Override - public byte[] evalSha(String sha1, List keys, List arguments) { + public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) { + boolean mutation = + invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE + && invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI; + ScriptOutputType outputType = + invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + || invocation.replyShape() + == RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI + ? ScriptOutputType.MULTI + : ScriptOutputType.VALUE; try { - return execute( - true, - reservationBytes(256, keys, arguments), - () -> - commands.evalsha( - sha1, - ScriptOutputType.VALUE, - keys.toArray(byte[][]::new), - arguments.toArray(byte[][]::new))); + Object result = + execute( + mutation, + Math.max(256, invocation.encodedBytes()), + () -> + commands.evalsha( + RedisScriptRecovery.sha1( + RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)), + outputType, + RedisCatalogProgramInvocation.WireCodec.keysArray(invocation), + RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation))); + if (outputType == ScriptOutputType.MULTI) { + @SuppressWarnings("unchecked") + List fields = (List) result; + return RedisCatalogProgramReply.multi(defensiveReply(fields)); + } + return RedisCatalogProgramReply.value((byte[]) result); } catch (io.lettuce.core.RedisNoScriptException exception) { throw new RedisNoScriptException(); + } catch (RedisCommandExecutionException exception) { + if (exception.getMessage() != null + && exception.getMessage().contains(VALUE_TOO_LARGE_ERROR)) { + throw new RedisValueTooLargeException(); + } + throw commandFailure( + mutation, + mutation + ? "Redis Lua program execution failed" + : "Redis read-only Lua program execution failed", + exception); } } @Override - public byte[] eval(byte[] script, List keys, List arguments) { - return execute( + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + byte[] script = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation); + try { + return execute( + true, reservationBytes(64, List.of(script)), () -> commands.scriptLoad(script.clone())); + } catch (RedisCommandExecutionException exception) { + throw commandFailure(true, "Redis script load failed", exception); + } + } + + void publishInvalidation(String channel, String message) { + byte[] channelBytes = channel.getBytes(StandardCharsets.US_ASCII); + byte[] messageBytes = message.getBytes(StandardCharsets.US_ASCII); + execute( true, - reservationBytes(256, List.of(script), keys, arguments), - () -> - commands.eval( - script.clone(), - ScriptOutputType.VALUE, - keys.toArray(byte[][]::new), - arguments.toArray(byte[][]::new))); + reservationBytes(64, List.of(channelBytes, messageBytes)), + () -> commands.publish(channelBytes, messageBytes)); + } + + StatefulRedisPubSubConnection openInvalidationSubscription() { + ensureOpen(); + return client.connectPubSub(ByteArrayCodec.INSTANCE); } @Override @@ -252,7 +283,7 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut throw exception; } catch (RedisCommandInterruptedException exception) { Thread.currentThread().interrupt(); - throw exception; + throw commandFailure(mutation, "Redis command was interrupted", exception); } catch (RedisCommandTimeoutException exception) { throw commandFailure(mutation, "Redis command timed out", exception); } catch (RedisConnectionException exception) { @@ -273,6 +304,13 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut cause); } + private static List defensiveReply(List result) { + if (result == null) { + return null; + } + return result.stream().map(value -> value == null ? null : value.clone()).toList(); + } + @SafeVarargs private static int reservationBytes(int responseBytes, List... groups) { long total = Math.max(1, responseBytes); @@ -289,4 +327,20 @@ final class LettuceRedisRuntime implements RedisClient, RedisBinaryCommands, Aut } return (int) total; } + + static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial { + + private final byte[] encoded; + + private LegacyKeyMaterial(String key) { + this.encoded = + Objects.requireNonNull(key, "legacy key must be non-null") + .getBytes(StandardCharsets.UTF_8); + } + + @Override + public byte[] copyEncodedKey() { + return encoded.clone(); + } + } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPort.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPort.java new file mode 100644 index 0000000..b27cdfa --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPort.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.cache.CacheObservationEvent; +import dev.caskeleton.application.cache.CacheObservationPort; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** Micrometer rendering for the framework-free cache observation boundary. */ +final class MicrometerCacheObservationPort implements CacheObservationPort { + + private final MeterRegistry registry; + private final Set cacheNames; + + MicrometerCacheObservationPort(MeterRegistry registry, Set cacheNames) { + this.registry = Objects.requireNonNull(registry, "registry must be non-null"); + this.cacheNames = Set.copyOf(Objects.requireNonNull(cacheNames, "cacheNames must be non-null")); + if (this.cacheNames.isEmpty() || this.cacheNames.size() > 50) { + throw new IllegalArgumentException("cacheNames must contain 1..50 startup-registered names"); + } + if (this.cacheNames.stream().anyMatch(name -> name == null || name.isBlank())) { + throw new IllegalArgumentException("cacheNames must contain non-blank names"); + } + } + + @Override + public void observe(CacheObservationEvent event) { + Objects.requireNonNull(event, "event must be non-null"); + String cacheName = + event instanceof CacheObservationEvent.Lookup lookup + ? lookup.cacheName() + : ((CacheObservationEvent.LocalMaintenance) event).cacheName(); + if (!cacheNames.contains(cacheName)) { + throw new IllegalArgumentException("cacheName is not in the startup allowlist"); + } + if (event instanceof CacheObservationEvent.Lookup lookup) { + observeLookup(lookup); + return; + } + CacheObservationEvent.LocalMaintenance maintenance = + (CacheObservationEvent.LocalMaintenance) event; + Counter.builder("cache.local.maintenance.total") + .tag("cache_name", maintenance.cacheName()) + .tag("event", maintenanceEvent(maintenance)) + .register(registry) + .increment(); + } + + private void observeLookup(CacheObservationEvent.Lookup lookup) { + if (lookup.tier() != CacheObservationEvent.Tier.LOCAL_L1) { + return; + } + Counter.builder("cache.local.requests.total") + .tag("cache_name", lookup.cacheName()) + .tag("result", lower(lookup.result())) + .register(registry) + .increment(); + if (lookup.result() == CacheObservationEvent.LookupResult.HIT) { + Timer.builder("cache.local.entry.age.seconds") + .tag("cache_name", lookup.cacheName()) + .register(registry) + .record(lookup.entryAge()); + } + } + + private static String lower(Enum value) { + return value.name().toLowerCase(Locale.ROOT); + } + + private static String maintenanceEvent(CacheObservationEvent.LocalMaintenance event) { + if (event.action() == CacheObservationEvent.MaintenanceAction.EVICT) { + return "evict_" + lower(event.cause()); + } + if (event.cause() == CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED) { + return "reconcile_generation_changed"; + } + if (event.action() == CacheObservationEvent.MaintenanceAction.RECONCILE) { + return event.result() == CacheObservationEvent.MaintenanceResult.ERROR + ? "reconcile_error" + : "reconcile_unchanged"; + } + if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED) { + return "subscriber_disconnected"; + } + if (event.cause() == CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW) { + return "subscriber_overflow"; + } + if (event.cause() == CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE) { + return "subscriber_malformed"; + } + if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT + && event.result() == CacheObservationEvent.MaintenanceResult.FLUSHED) { + return "flush_invalidation"; + } + if (event.action() == CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT) { + return event.result() == CacheObservationEvent.MaintenanceResult.SUCCESS + ? "subscriber_publish_success" + : "subscriber_publish_error"; + } + if (event.cause() == CacheObservationEvent.MaintenanceCause.INVALIDATION) { + return "flush_invalidation"; + } + return "other"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPort.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPort.java new file mode 100644 index 0000000..7376a65 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPort.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Renders the closed Redis capability event model to its six registry-approved meters. */ +final class MicrometerRedisCapabilityObservationPort implements RedisCapabilityObservationPort { + + private final MeterRegistry registry; + private final ConcurrentMap< + RedisCapabilityObservationEvent.Role, AtomicReference> + inFlight = new ConcurrentHashMap<>(); + + MicrometerRedisCapabilityObservationPort(MeterRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry must be non-null"); + } + + @Override + public void observe(RedisCapabilityObservationEvent.Event event) { + Objects.requireNonNull(event, "event must be non-null"); + switch (event) { + case RedisCapabilityObservationEvent.OperationCompleted operation -> + observeOperation(operation); + case RedisCapabilityObservationEvent.AdmissionChanged admission -> + observeAdmission(admission); + case RedisCapabilityObservationEvent.ReadinessObserved readiness -> + observeReadiness(readiness); + case RedisCapabilityObservationEvent.LifecycleDrainCompleted lifecycle -> + observeLifecycle(lifecycle); + } + } + + private void observeOperation(RedisCapabilityObservationEvent.OperationCompleted event) { + Counter.builder("redis.capability.operations.total") + .tags( + "capability", lower(event.capability()), + "role", lower(event.role()), + "operation", lower(event.operation()), + "redis_outcome", lower(event.outcome()), + "certainty", lower(event.certainty())) + .register(registry) + .increment(); + Timer.builder("redis.capability.duration.seconds") + .tags( + "capability", lower(event.capability()), + "role", lower(event.role()), + "operation", lower(event.operation()), + "redis_outcome", lower(event.outcome())) + .register(registry) + .record(event.durationNanos(), TimeUnit.NANOSECONDS); + } + + private void observeAdmission(RedisCapabilityObservationEvent.AdmissionChanged event) { + if (event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED + || event.admission() == RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED) { + Counter.builder("redis.capability.admission.rejected.total") + .tags("role", lower(event.role()), "admission", lower(event.admission())) + .register(registry) + .increment(); + } + snapshot(event.role()).set(new InFlightSnapshot(event.state(), event.inFlightCommands())); + } + + private void observeReadiness(RedisCapabilityObservationEvent.ReadinessObserved event) { + Counter.builder("redis.capability.readiness.total") + .tags( + "capability", lower(event.capability()), + "role", lower(event.role()), + "state", lower(event.state()), + "reason", lower(event.reason()), + "requirement", lower(event.requirement())) + .register(registry) + .increment(); + } + + private void observeLifecycle(RedisCapabilityObservationEvent.LifecycleDrainCompleted event) { + Counter.builder("redis.capability.lifecycle.drain.total") + .tags("role", lower(event.role()), "drain_outcome", lower(event.drainOutcome())) + .register(registry) + .increment(); + } + + private static String lower(Enum value) { + return value.name().toLowerCase(Locale.ROOT); + } + + private AtomicReference snapshot(RedisCapabilityObservationEvent.Role role) { + return inFlight.computeIfAbsent( + role, + ignored -> { + AtomicReference value = + new AtomicReference<>( + new InFlightSnapshot(RedisCapabilityObservationEvent.InFlightState.IDLE, 0)); + for (RedisCapabilityObservationEvent.InFlightState state : + RedisCapabilityObservationEvent.InFlightState.values()) { + Gauge.builder( + "redis.capability.inflight.total", + value, + reference -> { + InFlightSnapshot current = reference.get(); + return current.state() == state ? current.commands() : 0; + }) + .tags("role", lower(role), "state", lower(state)) + .register(registry); + } + return value; + }); + } + + private record InFlightSnapshot( + RedisCapabilityObservationEvent.InFlightState state, int commands) {} +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/NoOpRedisCapabilityObservationPort.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/NoOpRedisCapabilityObservationPort.java new file mode 100644 index 0000000..c4da506 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/NoOpRedisCapabilityObservationPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +enum NoOpRedisCapabilityObservationPort implements RedisCapabilityObservationPort { + INSTANCE; + + static RedisCapabilityObservationPort instance() { + return INSTANCE; + } + + @Override + public void observe(RedisCapabilityObservationEvent.Event event) { + // Intentionally disabled. + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java index bc548ed..f6aa022 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitives.java @@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.cache.redis; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; import java.util.List; import java.util.Objects; @@ -10,8 +11,9 @@ final class RedisAtomicPrimitives { private static final int MAXIMUM_OWNER_BYTES = 128; private static final int MAXIMUM_OPERATION_ID_BYTES = 128; - private static final int MAXIMUM_VALUE_BYTES = 1_048_576; + private static final int MAXIMUM_VALUE_BYTES = 16_778_272; private static final long MAXIMUM_TTL_MILLIS = Duration.ofDays(30).toMillis(); + private static final long MAXIMUM_CONTROL_TTL_MILLIS = Duration.ofDays(31).toMillis(); private final RedisProgramCatalog catalog; private final RedisProgramExecutor executor; @@ -56,12 +58,110 @@ final class RedisAtomicPrimitives { return parse(RedisProgramId.SET_IF_ABSENT_WITH_TTL, status, SetIfAbsentResult.class); } + ReplaceIfObservedResult replaceIfObservedWithTtl( + String key, String observationToken, byte[] value, Duration timeToLive, String operationId) { + byte[] keyBytes = key(key); + byte[] expectedDigest = observationDigest(observationToken); + byte[] boundedValue = bounded(value, MAXIMUM_VALUE_BYTES, "value"); + byte[] ttl = ttl(timeToLive); + byte[] operation = + bounded( + Objects.requireNonNull(operationId, "operationId must be non-null") + .getBytes(StandardCharsets.UTF_8), + MAXIMUM_OPERATION_ID_BYTES, + "operationId"); + String status = + execute( + RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, + List.of(keyBytes), + List.of(expectedDigest, boundedValue, ttl, operation)); + return parse( + RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, status, ReplaceIfObservedResult.class); + } + + GenerationInitResult initializeGeneration(String key, String candidateGeneration) { + return initializeGeneration(key, candidateGeneration, Duration.ZERO); + } + + GenerationInitResult initializeGeneration( + String key, String candidateGeneration, Duration timeToLive) { + byte[] keyBytes = key(key); + byte[] generation = identifier(candidateGeneration, "candidateGeneration"); + byte[] ttl = controlTtl(timeToLive); + String status = + execute(RedisProgramId.REGION_GENERATION_INIT, List.of(keyBytes), List.of(generation, ttl)); + return parse(RedisProgramId.REGION_GENERATION_INIT, status, GenerationInitResult.class); + } + + GenerationBumpResult bumpGeneration(String key, String candidateGeneration, String operationId) { + return bumpGeneration(key, candidateGeneration, operationId, Duration.ZERO); + } + + GenerationBumpResult bumpGeneration( + String key, String candidateGeneration, String operationId, Duration timeToLive) { + byte[] keyBytes = key(key); + byte[] generation = identifier(candidateGeneration, "candidateGeneration"); + byte[] operation = identifier(operationId, "operationId"); + byte[] ttl = controlTtl(timeToLive); + String status = + execute( + RedisProgramId.REGION_GENERATION_BUMP, + List.of(keyBytes), + List.of(generation, operation, ttl)); + return parse(RedisProgramId.REGION_GENERATION_BUMP, status, GenerationBumpResult.class); + } + + RefreshClaimResult claimRefreshLease( + String key, String ownerToken, String operationToken, Duration timeToLive) { + byte[] keyBytes = key(key); + byte[] owner = identifier(ownerToken, "ownerToken"); + byte[] operation = identifier(operationToken, "operationToken"); + byte[] ttl = refreshLeaseTtl(timeToLive); + String status = + execute( + RedisProgramId.CACHE_REFRESH_CLAIM, List.of(keyBytes), List.of(owner, operation, ttl)); + return parse(RedisProgramId.CACHE_REFRESH_CLAIM, status, RefreshClaimResult.class); + } + private String execute(RedisProgramId id, List keys, List arguments) { RedisProgramDescriptor descriptor = catalog.descriptor(id); if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { throw new IllegalStateException("typed Redis program signature drift for " + id.externalId()); } - return executor.execute(descriptor, List.copyOf(keys), List.copyOf(arguments)); + return executor.execute(catalog.capabilityInvocation(new ProgramMaterial(id, keys, arguments))); + } + + static final class ProgramMaterial implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final List keys; + private final List arguments; + + private ProgramMaterial(RedisProgramId programId, List keys, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.keys = keys.stream().map(byte[]::clone).toList(); + this.arguments = arguments.stream().map(byte[]::clone).toList(); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return RedisCatalogProgramInvocation.ReplyShape.VALUE; + } + + @Override + public List copyKeys() { + return keys.stream().map(byte[]::clone).toList(); + } + + @Override + public List copyArguments() { + return arguments.stream().map(byte[]::clone).toList(); + } } private static byte[] key(String key) { @@ -84,6 +184,66 @@ final class RedisAtomicPrimitives { return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII); } + private static byte[] controlTtl(Duration timeToLive) { + Objects.requireNonNull(timeToLive, "timeToLive must be non-null"); + long milliseconds; + try { + milliseconds = timeToLive.toMillis(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("control TTL exceeds supported range", exception); + } + if (milliseconds < 0 || milliseconds > MAXIMUM_CONTROL_TTL_MILLIS) { + throw new IllegalArgumentException( + "control TTL must be between 0 and " + MAXIMUM_CONTROL_TTL_MILLIS + " milliseconds"); + } + return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] refreshLeaseTtl(Duration timeToLive) { + Objects.requireNonNull(timeToLive, "timeToLive must be non-null"); + long milliseconds; + try { + milliseconds = timeToLive.toMillis(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("refresh lease TTL exceeds supported range", exception); + } + long maximum = Duration.ofMinutes(5).toMillis(); + if (milliseconds < 1 || milliseconds > maximum) { + throw new IllegalArgumentException( + "refresh lease TTL must be between 1 and " + maximum + " milliseconds"); + } + return Long.toString(milliseconds).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] observationDigest(String observationToken) { + Objects.requireNonNull(observationToken, "observationToken must be non-null"); + byte[] digest; + try { + digest = Base64.getUrlDecoder().decode(observationToken); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("observationToken must be unpadded Base64URL", exception); + } + if (digest.length != 32 + || !Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(digest) + .equals(observationToken)) { + throw new IllegalArgumentException( + "observationToken must encode exactly one canonical SHA-256 digest"); + } + return digest; + } + + private static byte[] identifier(String value, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + if (bytes.length < 16 || bytes.length > 64 || !value.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException( + field + " must be a Base64URL-safe identifier of 16..64 bytes"); + } + return bytes; + } + private static byte[] bounded(byte[] value, int maximumBytes, String field) { Objects.requireNonNull(value, field + " must be non-null"); if (value.length < 1 || value.length > maximumBytes) { @@ -123,4 +283,34 @@ final class RedisAtomicPrimitives { WRONG_TYPE, INVALID } + + enum ReplaceIfObservedResult { + REPLACED, + ABSENT, + NOT_MATCHED, + WRONG_TYPE, + INVALID + } + + enum GenerationInitResult { + INITIALIZED, + EXISTING, + WRONG_TYPE, + INVALID + } + + enum GenerationBumpResult { + BUMPED, + ALREADY_APPLIED, + WRONG_TYPE, + INVALID + } + + enum RefreshClaimResult { + CLAIMED, + ALREADY_OWNED, + CONTENDED, + WRONG_TYPE, + INVALID + } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java index 4873bf4..38450ab 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryCommands.java @@ -1,18 +1,13 @@ package dev.caskeleton.adapter.outbound.cache.redis; import java.time.Duration; -import java.util.List; /** Minimal binary Redis command surface owned entirely by this adapter. */ -interface RedisBinaryCommands { +interface RedisBinaryCommands extends RedisStructuredCommands { - byte[] get(byte[] key); + byte[] get(RedisPhysicalKey key); - void set(byte[] key, byte[] value, Duration timeToLive); + void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive); - long delete(byte[] key); - - byte[] evalSha(String sha1, List keys, List arguments); - - byte[] eval(byte[] script, List keys, List arguments); + long delete(RedisPhysicalKey key); } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryValue.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryValue.java new file mode 100644 index 0000000..ad516bc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBinaryValue.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Opaque bounded adapter-private value crossing the command gateway. */ +final class RedisBinaryValue { + + private static final int MAXIMUM_VALUE_BYTES = 16_777_216; + + private final byte[] encoded; + + private RedisBinaryValue(byte[] encoded) { + Objects.requireNonNull(encoded, "Redis binary value must be non-null"); + if (encoded.length < 1 || encoded.length > MAXIMUM_VALUE_BYTES) { + throw new IllegalArgumentException("Redis binary value is out of bounds"); + } + this.encoded = encoded.clone(); + } + + static RedisBinaryValue encoded(byte[] encoded) { + return new RedisBinaryValue(encoded); + } + + static RedisBinaryValue utf8(String encoded) { + Objects.requireNonNull(encoded, "Redis binary value must be non-null"); + return new RedisBinaryValue(encoded.getBytes(StandardCharsets.UTF_8)); + } + + int encodedLength() { + return encoded.length; + } + + byte[] copyEncoded() { + return encoded.clone(); + } + + @Override + public String toString() { + return "RedisBinaryValue[redacted]"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapByteOffset.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapByteOffset.java new file mode 100644 index 0000000..af0838f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapByteOffset.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Descriptor-owned byte offset for BITCOUNT ranges (Redis BITCOUNT is byte-indexed). */ +record RedisBitmapByteOffset(long value) { + + RedisBitmapByteOffset { + if (value < 0 || value >= 1_048_576) { + throw new IllegalArgumentException("bitmap byte offset exceeds fixed descriptor domain"); + } + } + + static RedisBitmapByteOffset of(long value) { + return new RedisBitmapByteOffset(value); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapMutationResult.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapMutationResult.java new file mode 100644 index 0000000..5d43252 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapMutationResult.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.OptionalInt; + +/** SETBIT result preserves the previous bit instead of mislabelling it as an affected count. */ +record RedisBitmapMutationResult( + Status status, RedisPrimitiveMutationResult.Certainty certainty, OptionalInt previousBit) { + + enum Status { + APPLIED, + WRONG_TYPE, + UNKNOWN + } + + RedisBitmapMutationResult { + if (status == null || certainty == null || previousBit == null) { + throw new IllegalArgumentException("bitmap mutation result is invalid"); + } + previousBit.ifPresent( + bit -> { + if (bit != 0 && bit != 1) { + throw new IllegalArgumentException("previous bitmap bit is invalid"); + } + }); + } + + static RedisBitmapMutationResult from(RedisPrimitiveReply reply) { + return switch (reply.status()) { + case APPLIED -> + new RedisBitmapMutationResult( + Status.APPLIED, + RedisPrimitiveMutationResult.Certainty.APPLIED, + OptionalInt.of(Math.toIntExact(reply.signedNumber().orElseThrow()))); + case WRONG_TYPE -> + new RedisBitmapMutationResult( + Status.WRONG_TYPE, + RedisPrimitiveMutationResult.Certainty.NOT_APPLIED, + OptionalInt.empty()); + default -> + new RedisBitmapMutationResult( + Status.UNKNOWN, + RedisPrimitiveMutationResult.Certainty.NOT_APPLIED, + OptionalInt.empty()); + }; + } + + static RedisBitmapMutationResult failed(RedisCommandFailureException failure) { + return new RedisBitmapMutationResult( + Status.UNKNOWN, + failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? RedisPrimitiveMutationResult.Certainty.INDETERMINATE + : RedisPrimitiveMutationResult.Certainty.NOT_APPLIED, + OptionalInt.empty()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapOffset.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapOffset.java new file mode 100644 index 0000000..ba9c247 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapOffset.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Offset constrained to a descriptor-owned fixed bitmap domain. */ +record RedisBitmapOffset(long value, long maximumExclusive) { + + RedisBitmapOffset { + if (maximumExclusive < 1 || value < 0 || value >= maximumExclusive) { + throw new IllegalArgumentException("bitmap offset exceeds the fixed descriptor domain"); + } + } + + static RedisBitmapOffset of(long value, long maximumExclusive) { + return new RedisBitmapOffset(value, maximumExclusive); + } + + long byteIndex() { + return value / Byte.SIZE; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapPrimitives.java new file mode 100644 index 0000000..06eb495 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBitmapPrimitives.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; +import java.util.Objects; + +/** Fixed-domain non-authoritative bitmap helpers. */ +final class RedisBitmapPrimitives { + + private static final long MAXIMUM_OFFSET_EXCLUSIVE = 8_388_608; + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + + RedisBitmapPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.BITMAP_GET).key(slot, identity); + } + + RedisBitmapOffset offset(long value) { + return RedisBitmapOffset.of(value, MAXIMUM_OFFSET_EXCLUSIVE); + } + + RedisBitmapByteOffset byteOffset(long value) { + return RedisBitmapByteOffset.of(value); + } + + RedisPrimitiveReply get(RedisPrimitiveKey key, RedisBitmapOffset offset) { + return executor.execute( + RedisPrimitiveId.BITMAP_GET, + List.of(key), + new RedisPrimitiveInvocation.BitmapArguments(offset, offset, -1)); + } + + RedisBitmapMutationResult set(RedisPrimitiveKey key, RedisBitmapOffset offset, boolean bit) { + try { + return RedisBitmapMutationResult.from( + executor.execute( + RedisPrimitiveId.BITMAP_SET, + List.of(key), + new RedisPrimitiveInvocation.BitmapArguments(offset, offset, bit ? 1 : 0))); + } catch (RedisCommandFailureException failure) { + return RedisBitmapMutationResult.failed(failure); + } + } + + RedisPrimitiveReply count( + RedisPrimitiveKey key, RedisBitmapByteOffset first, RedisBitmapByteOffset last) { + return executor.execute( + RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE, + List.of(key), + new RedisPrimitiveInvocation.BitmapCountArguments(first, last)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodec.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodec.java new file mode 100644 index 0000000..ed3ce63 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodec.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import io.lettuce.core.codec.RedisCodec; +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * Rejects an oversized Redis bulk value before allocating its destination byte array. + * + *

RESP aggregate element count and aggregate reply bytes are additionally checked by the + * semantic router because a codec invocation sees only one bulk element. Lettuce constructs the + * aggregate list before that final check, so multi-value commands remain restricted to the vetted + * program catalog and its bounded reply schemas; this codec is the pre-allocation bound for each + * bulk element, not a claim of a pre-allocation aggregate-list bound. + */ +final class RedisBoundedByteArrayCodec implements RedisCodec { + + private final int maximumBulkBytes; + + RedisBoundedByteArrayCodec(int maximumBulkBytes) { + if (maximumBulkBytes < 1024 || maximumBulkBytes > 16_777_216) { + throw new IllegalArgumentException("Redis codec bulk byte bound must be in 1024..16777216"); + } + this.maximumBulkBytes = maximumBulkBytes; + } + + @Override + public byte[] decodeKey(ByteBuffer bytes) { + return decode(bytes); + } + + @Override + public byte[] decodeValue(ByteBuffer bytes) { + return decode(bytes); + } + + @Override + public ByteBuffer encodeKey(byte[] key) { + return encode(key); + } + + @Override + public ByteBuffer encodeValue(byte[] value) { + return encode(value); + } + + private byte[] decode(ByteBuffer bytes) { + Objects.requireNonNull(bytes, "Redis decode buffer must be non-null"); + if (bytes.remaining() > maximumBulkBytes) { + throw new IllegalStateException("Redis response bulk value exceeds its configured bound"); + } + byte[] value = new byte[bytes.remaining()]; + bytes.get(value); + return value; + } + + private ByteBuffer encode(byte[] value) { + Objects.requireNonNull(value, "Redis encode value must be non-null"); + if (value.length > maximumBulkBytes) { + throw new IllegalArgumentException("Redis command bulk value exceeds its configured bound"); + } + return ByteBuffer.wrap(value); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java index a4b4b8b..9794d79 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java @@ -2,7 +2,14 @@ package dev.caskeleton.adapter.outbound.cache.redis; import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; -import dev.caskeleton.application.cache.CacheRegionPort; +import dev.caskeleton.application.cache.CacheObservationPort; +import dev.caskeleton.application.cache.DisabledCacheObservationPort; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.Clock; +import java.util.Arrays; +import java.util.Set; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -23,7 +30,11 @@ import org.springframework.context.annotation.Configuration; * therefore never need to know about each other — a new backend is new files only. */ @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(RedisRuntimeSettings.class) +@EnableConfigurationProperties({RedisRuntimeSettings.class, RedisLocalCacheSettings.class}) +@ConditionalOnProperty( + name = "ca-skeleton.providers.redis.legacy-migration-enabled", + havingValue = "true", + matchIfMissing = false) public class RedisCacheAdapterConfig { @Configuration(proxyBeanMethods = false) @@ -44,14 +55,17 @@ public class RedisCacheAdapterConfig { } } - @Bean + @Bean(destroyMethod = "close") @ConditionalOnBean(LettuceRedisRuntime.class) @ConditionalOnProperty( name = "app.cache.redis.enabled", havingValue = "true", matchIfMissing = false) - CacheRegionPort redisStringCacheRegion( - LettuceRedisRuntime runtime, RedisRuntimeSettings settings) { + RedisCacheRegionRuntime redisStringCacheRegion( + LettuceRedisRuntime runtime, + RedisRuntimeSettings settings, + RedisLocalCacheSettings localSettings, + ObjectProvider meterRegistryProvider) { RedisKeyNamespace namespace = new RedisKeyNamespace( settings.namespaceApplication(), @@ -62,14 +76,75 @@ public class RedisCacheAdapterConfig { 1, "entry", 512); - return new RedisStringCacheRegion( - new RedisCacheRegionPolicy( - namespace, - settings.hmacSecret(), - settings.positiveTtl(), - settings.negativeTtl(), - settings.maximumValueBytes()), - runtime); + byte[] policySecret = settings.hmacSecret(); + RedisCacheRegionPolicy policy; + try { + policy = + new RedisCacheRegionPolicy( + namespace, + policySecret, + "runtime-settings-v2", + settings.positiveSoftTtl(), + settings.positiveTtl(), + settings.negativeTtl(), + settings.ttlJitter(), + settings.minimumHardTtl(), + settings.maximumValueBytes()); + } finally { + Arrays.fill(policySecret, (byte) 0); + } + RedisStringCacheRegion l2 = new RedisStringCacheRegion(policy, runtime); + if (!localSettings.enabled()) { + return RedisCacheRegionRuntime.l2Only(l2); + } + MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable(); + CacheObservationPort observations = + meterRegistry == null + ? DisabledCacheObservationPort.instance() + : new MicrometerCacheObservationPort(meterRegistry, Set.of(settings.semanticRegion())); + String channel = l2.invalidationChannel(); + byte[] codecSecret = settings.hmacSecret(); + RedisCacheInvalidationMessage.Codec codec; + try { + codec = RedisCacheInvalidationMessage.Codec.fromOwnedSecret(codecSecret); + } finally { + Arrays.fill(codecSecret, (byte) 0); + } + return RedisCacheRegionRuntime.local( + l2, + new RedisLocalCacheRegion( + settings.semanticRegion(), + l2, + localSettings.policy(), + Clock.systemUTC(), + observations, + channel, + codec, + message -> runtime.publishInvalidation(channel, message))); + } + + @Bean(destroyMethod = "close") + @ConditionalOnBean(LettuceRedisRuntime.class) + @ConditionalOnProperty( + name = {"app.cache.redis.enabled", "app.cache.redis.l1.enabled"}, + havingValue = "true", + matchIfMissing = false) + LettuceRedisCacheInvalidationSubscription redisCacheInvalidationSubscription( + LettuceRedisRuntime runtime, + @Qualifier("redisStringCacheRegion") RedisCacheRegionRuntime cacheRegion) { + RedisLocalCacheRegion local = + cacheRegion + .local() + .orElseThrow( + () -> + new IllegalStateException( + "Redis L1 invalidation subscription requires the cache-only local" + + " decorator")); + return LettuceRedisCacheInvalidationSubscription.subscribe( + runtime, + local.invalidationChannel(), + local.invalidationMessageCodec(), + local.invalidationSubscriber()); } @Bean diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStore.java new file mode 100644 index 0000000..aaeb535 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStore.java @@ -0,0 +1,219 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.cache.CacheWriteCondition; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Owns non-expiring random cache generations and per-key revisions. + * + *

If an evictable control key disappears, initialization chooses a new random value. An old + * namespace therefore never becomes visible again by resetting to a constant default. + */ +final class RedisCacheConsistencyStore { + + private static final String CONDITION_VERSION = "v1"; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Duration DEFAULT_KEY_REVISION_TTL = Duration.ofDays(30); + + private final RedisBinaryCommands commands; + private final RedisAtomicPrimitives primitives; + private final Supplier identifiers; + private final Duration keyRevisionTtl; + + RedisCacheConsistencyStore(RedisBinaryCommands commands) { + this(commands, DEFAULT_KEY_REVISION_TTL); + } + + RedisCacheConsistencyStore(RedisBinaryCommands commands, Duration keyRevisionTtl) { + this( + commands, + productionPrimitives(commands), + RedisCacheConsistencyStore::randomIdentifier, + keyRevisionTtl); + } + + RedisCacheConsistencyStore( + RedisBinaryCommands commands, + RedisAtomicPrimitives primitives, + Supplier identifiers) { + this(commands, primitives, identifiers, DEFAULT_KEY_REVISION_TTL); + } + + RedisCacheConsistencyStore( + RedisBinaryCommands commands, + RedisAtomicPrimitives primitives, + Supplier identifiers, + Duration keyRevisionTtl) { + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null"); + this.identifiers = Objects.requireNonNull(identifiers, "identifiers must be non-null"); + this.keyRevisionTtl = boundedKeyRevisionTtl(keyRevisionTtl); + } + + Snapshot capture(String regionGenerationKey, String keyRevisionKey) { + return new Snapshot( + currentOrInitialize(regionGenerationKey, Duration.ZERO), + currentOrInitialize(keyRevisionKey, keyRevisionTtl)); + } + + String currentRegionGeneration(String regionGenerationKey) { + return currentOrInitialize(regionGenerationKey, Duration.ZERO); + } + + BumpResult bumpKeyRevision(String keyRevisionKey) { + return bumpKeyRevision(keyRevisionKey, nextIdentifier()); + } + + BumpResult bumpKeyRevision(String keyRevisionKey, String operationId) { + return bump(keyRevisionKey, operationId, keyRevisionTtl); + } + + BumpResult bumpRegionGeneration(String regionGenerationKey) { + return bumpRegionGeneration(regionGenerationKey, nextIdentifier()); + } + + BumpResult bumpRegionGeneration(String regionGenerationKey, String operationId) { + return bump(regionGenerationKey, operationId, Duration.ZERO); + } + + Snapshot decode(CacheWriteCondition condition) { + Objects.requireNonNull(condition, "condition must be non-null"); + if (!condition.usable()) { + return null; + } + String[] components = condition.value().split("\\.", -1); + if (components.length != 3 || !CONDITION_VERSION.equals(components[0])) { + throw compatibility("MALFORMED_WRITE_CONDITION"); + } + try { + return new Snapshot(components[1], components[2]); + } catch (IllegalArgumentException exception) { + throw compatibility("MALFORMED_WRITE_CONDITION"); + } + } + + private String currentOrInitialize(String key, Duration timeToLive) { + byte[] current = commands.get(physicalKey(key)); + String candidate = current == null ? nextIdentifier() : parseState(current).generation(); + RedisAtomicPrimitives.GenerationInitResult initialized = + primitives.initializeGeneration(key, candidate, timeToLive); + if (initialized == RedisAtomicPrimitives.GenerationInitResult.WRONG_TYPE + || initialized == RedisAtomicPrimitives.GenerationInitResult.INVALID) { + throw compatibility(initialized.name()); + } + current = commands.get(physicalKey(key)); + if (current == null) { + throw compatibility("MISSING_AFTER_INITIALIZATION"); + } + return parseState(current).generation(); + } + + private BumpResult bump(String key, String operationId, Duration timeToLive) { + RedisAtomicPrimitives.GenerationBumpResult result = + primitives.bumpGeneration( + key, nextIdentifier(), validateIdentifier(operationId, "operationId"), timeToLive); + return switch (result) { + case BUMPED -> BumpResult.BUMPED; + case ALREADY_APPLIED -> BumpResult.ALREADY_APPLIED; + case WRONG_TYPE, INVALID -> throw compatibility(result.name()); + }; + } + + private String nextIdentifier() { + return validateIdentifier(identifiers.get(), "generated identifier"); + } + + private static State parseState(byte[] value) { + String state = new String(value, StandardCharsets.US_ASCII); + int separator = state.indexOf('|'); + if (separator < 0 || separator != state.lastIndexOf('|')) { + throw compatibility("MALFORMED_GENERATION_STATE"); + } + try { + String generation = validateIdentifier(state.substring(0, separator), "stored generation"); + String operation = state.substring(separator + 1); + if (!"-".equals(operation)) { + validateIdentifier(operation, "stored operation"); + } + return new State(generation, operation); + } catch (IllegalArgumentException exception) { + throw compatibility("MALFORMED_GENERATION_STATE"); + } + } + + private static String validateIdentifier(String value, String field) { + if (value == null + || value.length() < 16 + || value.length() > 64 + || !value.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException(field + " must contain 16..64 Base64URL-safe characters"); + } + return value; + } + + private static Duration boundedKeyRevisionTtl(Duration value) { + Objects.requireNonNull(value, "keyRevisionTtl must be non-null"); + if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(31)) > 0) { + throw new IllegalArgumentException("keyRevisionTtl must be positive and at most 31 days"); + } + return value; + } + + private static RedisPhysicalKey physicalKey(String key) { + return RedisPhysicalKey.owned(new ConsistencyKeyMaterial(key)); + } + + private static String randomIdentifier() { + byte[] random = new byte[16]; + RANDOM.nextBytes(random); + return Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) { + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands)); + } + + private static RedisProgramCompatibilityException compatibility(String status) { + return new RedisProgramCompatibilityException(RedisProgramId.REGION_GENERATION_INIT, status); + } + + enum BumpResult { + BUMPED, + ALREADY_APPLIED + } + + record Snapshot(String generation, String keyRevision) { + + Snapshot { + generation = validateIdentifier(generation, "generation"); + keyRevision = validateIdentifier(keyRevision, "keyRevision"); + } + + CacheWriteCondition toWriteCondition() { + return new CacheWriteCondition(CONDITION_VERSION + "." + generation + "." + keyRevision); + } + } + + private record State(String generation, String operation) {} + + static final class ConsistencyKeyMaterial implements RedisOwnedPhysicalKeyMaterial { + + private final byte[] encodedKey; + + private ConsistencyKeyMaterial(String key) { + this.encodedKey = + Objects.requireNonNull(key, "key must be non-null").getBytes(StandardCharsets.UTF_8); + } + + @Override + public byte[] copyEncodedKey() { + return encodedKey.clone(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java index c4046d1..f2be658 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheEnvelopeCodec.java @@ -2,117 +2,186 @@ package dev.caskeleton.adapter.outbound.cache.redis; import dev.caskeleton.application.cache.AuthoritativeAbsence; import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheObservationToken; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Instant; import java.util.Arrays; +import java.util.Base64; import java.util.Objects; /** Strict versioned binary envelope for positive and authoritative-negative cache entries. */ final class RedisCacheEnvelopeCodec { private static final int MAGIC = 0x43414348; - private static final byte VERSION = 1; + private static final int VERSION = 2; private static final byte POSITIVE = 1; private static final byte NEGATIVE = 2; - private static final int CONTENT_HEADER_BYTES = - Integer.BYTES + Byte.BYTES + Byte.BYTES + Short.BYTES + Integer.BYTES; + private static final int COMMON_HEADER_BYTES = Integer.BYTES + Byte.BYTES + Byte.BYTES; + private static final int POSITIVE_HEADER_BYTES = + COMMON_HEADER_BYTES + Short.BYTES + Integer.BYTES + Long.BYTES + Long.BYTES; + private static final int NEGATIVE_HEADER_BYTES = COMMON_HEADER_BYTES + Integer.BYTES + Long.BYTES; private static final int DIGEST_BYTES = 32; private RedisCacheEnvelopeCodec() {} - static byte[] positive(String value, String sourceRevision, int maximumValueBytes) { - return encode( - POSITIVE, - utf8(Objects.requireNonNull(value, "value must be non-null")), - sourceRevision, - maximumValueBytes); - } - - static byte[] negative( - AuthoritativeAbsence reason, String sourceRevision, int maximumValueBytes) { - Objects.requireNonNull(reason, "reason must be non-null"); - return encode(NEGATIVE, utf8(reason.name()), sourceRevision, maximumValueBytes); - } - - static Decoded decode(byte[] envelope, int maximumValueBytes) { - if (envelope == null - || envelope.length < CONTENT_HEADER_BYTES + DIGEST_BYTES - || envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) { - return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + static byte[] positive( + String value, + String sourceRevision, + Instant softExpiresAt, + Instant hardExpiresAt, + int maximumValueBytes) { + Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null"); + Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null"); + if (softExpiresAt.isAfter(hardExpiresAt)) { + throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt"); } - try { - ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, envelope.length - DIGEST_BYTES); - if (buffer.getInt() != MAGIC) { - return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); - } - byte version = buffer.get(); - if (version > VERSION) { - return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION); - } - if (version < VERSION) { - return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION); - } - byte[] expectedDigest = sha256(Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES)); - byte[] actualDigest = - Arrays.copyOfRange(envelope, envelope.length - DIGEST_BYTES, envelope.length); - if (!MessageDigest.isEqual(expectedDigest, actualDigest)) { - return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE); - } - byte type = buffer.get(); - int revisionSize = Short.toUnsignedInt(buffer.getShort()); - int payloadSize = buffer.getInt(); - if (revisionSize < 1 - || revisionSize > 512 - || payloadSize < 1 - || payloadSize > maximumValueBytes - || buffer.remaining() != revisionSize + payloadSize) { - return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); - } - byte[] revision = new byte[revisionSize]; - byte[] payload = new byte[payloadSize]; - buffer.get(revision); - buffer.get(payload); - String sourceRevision = strictUtf8(revision); - if (!validSourceRevision(sourceRevision)) { - return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); - } - if (type == POSITIVE) { - return new Positive(strictUtf8(payload), sourceRevision); - } - if (type == NEGATIVE) { - return new Negative(AuthoritativeAbsence.valueOf(strictUtf8(payload))); - } - return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); - } catch (IllegalArgumentException | CharacterCodingException exception) { - return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); - } - } - - private static byte[] encode( - byte type, byte[] payload, String sourceRevision, int maximumValueBytes) { byte[] revision = utf8(Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null")); if (!validSourceRevision(sourceRevision) || revision.length > 512) { throw new IllegalArgumentException( "sourceRevision must contain 1..128 characters and at most 512 UTF-8 bytes"); } - if (payload.length < 1 || payload.length > maximumValueBytes) { - throw new IllegalArgumentException("cache payload exceeds configured maximum bytes"); - } + byte[] payload = + checkedPayload( + utf8(Objects.requireNonNull(value, "value must be non-null")), maximumValueBytes); byte[] content = - ByteBuffer.allocate(CONTENT_HEADER_BYTES + revision.length + payload.length) + ByteBuffer.allocate(POSITIVE_HEADER_BYTES + revision.length + payload.length) .putInt(MAGIC) - .put(VERSION) - .put(type) + .put((byte) VERSION) + .put(POSITIVE) .putShort((short) revision.length) .putInt(payload.length) + .putLong(softExpiresAt.toEpochMilli()) + .putLong(hardExpiresAt.toEpochMilli()) .put(revision) .put(payload) .array(); + return withDigest(content); + } + + static byte[] negative( + AuthoritativeAbsence reason, Instant hardExpiresAt, int maximumValueBytes) { + Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null"); + byte[] payload = checkedPayload(utf8(reason.name()), maximumValueBytes); + byte[] content = + ByteBuffer.allocate(NEGATIVE_HEADER_BYTES + payload.length) + .putInt(MAGIC) + .put((byte) VERSION) + .put(NEGATIVE) + .putInt(payload.length) + .putLong(hardExpiresAt.toEpochMilli()) + .put(payload) + .array(); + return withDigest(content); + } + + static Decoded decode(byte[] envelope, int maximumValueBytes) { + validateMaximumValueBytes(maximumValueBytes); + if (envelope == null) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE); + } + if (envelope.length < COMMON_HEADER_BYTES + DIGEST_BYTES + || envelope.length > maximumValueBytes + 1024 + DIGEST_BYTES) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE); + } + CacheObservationToken observationToken = CacheObservationToken.unavailable(); + try { + int contentLength = envelope.length - DIGEST_BYTES; + byte[] expectedDigest = sha256(Arrays.copyOf(envelope, contentLength)); + byte[] actualDigest = Arrays.copyOfRange(envelope, contentLength, envelope.length); + if (!MessageDigest.isEqual(expectedDigest, actualDigest)) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE); + } + observationToken = observationToken(actualDigest); + ByteBuffer buffer = ByteBuffer.wrap(envelope, 0, contentLength); + if (buffer.getInt() != MAGIC) { + return incompatible(CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, observationToken); + } + int version = Byte.toUnsignedInt(buffer.get()); + if (version > VERSION) { + return incompatible(CacheLookup.SchemaCategory.FUTURE_VERSION, observationToken); + } + if (version < VERSION) { + return incompatible(CacheLookup.SchemaCategory.RETIRED_VERSION, observationToken); + } + byte type = buffer.get(); + if (type == POSITIVE) { + return decodePositive(buffer, maximumValueBytes, observationToken); + } + if (type == NEGATIVE) { + return decodeNegative(buffer, maximumValueBytes, observationToken); + } + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken); + } catch (BufferUnderflowException + | IllegalArgumentException + | CharacterCodingException exception) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken); + } + } + + private static Decoded decodePositive( + ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken) + throws CharacterCodingException { + int revisionSize = Short.toUnsignedInt(buffer.getShort()); + int payloadSize = buffer.getInt(); + Instant softExpiresAt = Instant.ofEpochMilli(buffer.getLong()); + Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong()); + if (revisionSize < 1 + || revisionSize > 512 + || payloadSize < 1 + || payloadSize > maximumValueBytes + || buffer.remaining() != revisionSize + payloadSize + || softExpiresAt.isAfter(hardExpiresAt)) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken); + } + byte[] revision = new byte[revisionSize]; + byte[] payload = new byte[payloadSize]; + buffer.get(revision); + buffer.get(payload); + String sourceRevision = strictUtf8(revision); + if (!validSourceRevision(sourceRevision)) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken); + } + return new Positive( + strictUtf8(payload), sourceRevision, softExpiresAt, hardExpiresAt, observationToken); + } + + private static Decoded decodeNegative( + ByteBuffer buffer, int maximumValueBytes, CacheObservationToken observationToken) + throws CharacterCodingException { + int payloadSize = buffer.getInt(); + Instant hardExpiresAt = Instant.ofEpochMilli(buffer.getLong()); + if (payloadSize < 1 || payloadSize > maximumValueBytes || buffer.remaining() != payloadSize) { + return incompatible(CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, observationToken); + } + byte[] payload = new byte[payloadSize]; + buffer.get(payload); + return new Negative( + AuthoritativeAbsence.valueOf(strictUtf8(payload)), hardExpiresAt, observationToken); + } + + private static byte[] checkedPayload(byte[] payload, int maximumValueBytes) { + validateMaximumValueBytes(maximumValueBytes); + if (payload.length < 1 || payload.length > maximumValueBytes) { + throw new IllegalArgumentException("cache payload exceeds configured maximum bytes"); + } + return payload; + } + + private static void validateMaximumValueBytes(int maximumValueBytes) { + if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) { + throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216"); + } + } + + private static byte[] withDigest(byte[] content) { return ByteBuffer.allocate(content.length + DIGEST_BYTES) .put(content) .put(sha256(content)) @@ -136,6 +205,11 @@ final class RedisCacheEnvelopeCodec { return !sourceRevision.isBlank() && sourceRevision.length() <= 128; } + private static CacheObservationToken observationToken(byte[] digest) { + return new CacheObservationToken( + Base64.getUrlEncoder().withoutPadding().encodeToString(digest)); + } + private static byte[] sha256(byte[] content) { try { return MessageDigest.getInstance("SHA-256").digest(content); @@ -145,14 +219,28 @@ final class RedisCacheEnvelopeCodec { } private static Incompatible incompatible(CacheLookup.SchemaCategory category) { - return new Incompatible(category); + return new Incompatible(category, CacheObservationToken.unavailable()); + } + + private static Incompatible incompatible( + CacheLookup.SchemaCategory category, CacheObservationToken observationToken) { + return new Incompatible(category, observationToken); } sealed interface Decoded permits Positive, Negative, Incompatible {} - record Positive(String value, String sourceRevision) implements Decoded {} + record Positive( + String value, + String sourceRevision, + Instant softExpiresAt, + Instant hardExpiresAt, + CacheObservationToken observationToken) + implements Decoded {} - record Negative(AuthoritativeAbsence reason) implements Decoded {} + record Negative( + AuthoritativeAbsence reason, Instant hardExpiresAt, CacheObservationToken observationToken) + implements Decoded {} - record Incompatible(CacheLookup.SchemaCategory category) implements Decoded {} + record Incompatible(CacheLookup.SchemaCategory category, CacheObservationToken observationToken) + implements Decoded {} } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessage.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessage.java new file mode 100644 index 0000000..136ab89 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessage.java @@ -0,0 +1,164 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Authenticated, bounded Pub/Sub hint that contains no raw semantic cache key. */ +sealed interface RedisCacheInvalidationMessage { + + String value(); + + static RedisCacheInvalidationMessage key(String localEntryIdentity) { + return new Key(localEntryIdentity); + } + + static RedisCacheInvalidationMessage region(String generation) { + return new Region(generation); + } + + record Key(String value) implements RedisCacheInvalidationMessage { + + public Key { + value = boundedAscii(value, "localEntryIdentity", 1024); + } + } + + record Region(String value) implements RedisCacheInvalidationMessage { + + public Region { + if (value == null + || value.length() < 16 + || value.length() > 64 + || !value.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException( + "generation must contain 16..64 Base64URL-safe characters"); + } + } + } + + /** + * HMAC protects hints from cross-channel corruption; Redis ACL still owns publisher authority. + */ + final class Codec implements AutoCloseable { + + private static final int MAXIMUM_WIRE_CHARACTERS = 4096; + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + + private final byte[] secret; + private final AtomicBoolean destroyed = new AtomicBoolean(); + + Codec(byte[] secret) { + Objects.requireNonNull(secret, "secret must be non-null"); + if (secret.length < 32) { + throw new IllegalArgumentException("message HMAC secret must contain at least 32 bytes"); + } + this.secret = secret.clone(); + } + + static Codec fromOwnedSecret(byte[] ownedSecret) { + Objects.requireNonNull(ownedSecret, "ownedSecret must be non-null"); + try { + return new Codec(ownedSecret); + } finally { + Arrays.fill(ownedSecret, (byte) 0); + } + } + + synchronized String encode(RedisCacheInvalidationMessage message) { + ensureUsable(); + Objects.requireNonNull(message, "message must be non-null"); + String kind = message instanceof Key ? "K" : "R"; + byte[] payload = (kind + "\n" + message.value()).getBytes(StandardCharsets.US_ASCII); + return "v1." + ENCODER.encodeToString(payload) + "." + ENCODER.encodeToString(hmac(payload)); + } + + synchronized Optional decode(String wire) { + ensureUsable(); + if (wire == null || wire.length() < 8 || wire.length() > MAXIMUM_WIRE_CHARACTERS) { + return Optional.empty(); + } + String[] components = wire.split("\\.", -1); + if (components.length != 3 || !"v1".equals(components[0])) { + return Optional.empty(); + } + try { + byte[] payload = DECODER.decode(components[1]); + byte[] suppliedMac = DECODER.decode(components[2]); + if (!MessageDigest.isEqual(hmac(payload), suppliedMac)) { + return Optional.empty(); + } + String decoded = new String(payload, StandardCharsets.US_ASCII); + int separator = decoded.indexOf('\n'); + if (separator != 1 || separator != decoded.lastIndexOf('\n')) { + return Optional.empty(); + } + String value = decoded.substring(separator + 1); + return switch (decoded.charAt(0)) { + case 'K' -> Optional.of(key(value)); + case 'R' -> Optional.of(region(value)); + default -> Optional.empty(); + }; + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + private byte[] hmac(byte[] payload) { + byte[] secretCopy = secret.clone(); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretCopy, "HmacSHA256")); + return mac.doFinal(payload); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("HmacSHA256 unavailable for invalidation hints", exception); + } finally { + Arrays.fill(secretCopy, (byte) 0); + } + } + + @Override + public synchronized void close() { + if (destroyed.compareAndSet(false, true)) { + Arrays.fill(secret, (byte) 0); + } + } + + synchronized boolean destroyed() { + if (!destroyed.get()) { + return false; + } + for (byte value : secret) { + if (value != 0) { + return false; + } + } + return true; + } + + private void ensureUsable() { + if (destroyed.get()) { + throw new IllegalStateException("invalidation message codec is destroyed"); + } + } + } + + private static String boundedAscii(String value, String field, int maximumCharacters) { + if (value == null + || value.isBlank() + || value.length() > maximumCharacters + || value.chars().anyMatch(character -> character < 0x21 || character > 0x7e)) { + throw new IllegalArgumentException( + field + " must contain bounded non-whitespace ASCII characters"); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscriber.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscriber.java new file mode 100644 index 0000000..d89dba4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscriber.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; + +/** + * Bounded handoff between a Redis Pub/Sub callback and cache request threads. + * + *

Pub/Sub has no replay. Disconnect or queue overflow therefore flushes L1 immediately and + * forces a generation read before local entries may be repopulated. + */ +final class RedisCacheInvalidationSubscriber { + + interface Target { + + void apply(RedisCacheInvalidationMessage message); + + void disconnected(); + + void overflow(); + + void malformedMessage(); + } + + private final ArrayBlockingQueue hints; + private final Target target; + + RedisCacheInvalidationSubscriber(int capacity, Target target) { + if (capacity < 1 || capacity > 65_536) { + throw new IllegalArgumentException("subscriber capacity must be in 1..65536"); + } + this.hints = new ArrayBlockingQueue<>(capacity); + this.target = Objects.requireNonNull(target, "target must be non-null"); + } + + void onMessage(RedisCacheInvalidationMessage message) { + Objects.requireNonNull(message, "message must be non-null"); + if (hints.offer(message)) { + return; + } + hints.clear(); + target.overflow(); + } + + void onDisconnected() { + hints.clear(); + target.disconnected(); + } + + void onMalformedMessage() { + target.malformedMessage(); + } + + void drain() { + RedisCacheInvalidationMessage hint; + while ((hint = hints.poll()) != null) { + target.apply(hint); + } + } + + int queuedHintCount() { + return hints.size(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscription.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscription.java new file mode 100644 index 0000000..2a0d3a9 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationSubscription.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Lifecycle wrapper that decodes canonical CACHE-role invalidation traffic. */ +final class RedisCacheInvalidationSubscription implements AutoCloseable { + + private final RedisInvalidationTransport.Subscription delegate; + private final RedisCacheInvalidationSubscriber subscriber; + private final AtomicBoolean closed = new AtomicBoolean(); + + private RedisCacheInvalidationSubscription( + RedisInvalidationTransport.Subscription delegate, + RedisCacheInvalidationSubscriber subscriber) { + this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null"); + this.subscriber = Objects.requireNonNull(subscriber, "subscriber must be non-null"); + } + + static RedisCacheInvalidationSubscription subscribe( + RedisInvalidationTransport transport, + String channel, + RedisCacheInvalidationMessage.Codec codec, + RedisCacheInvalidationSubscriber subscriber) { + Objects.requireNonNull(transport, "transport must be non-null"); + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(codec, "codec must be non-null"); + Objects.requireNonNull(subscriber, "subscriber must be non-null"); + RedisInvalidationTransport.Subscription delegate = + transport.subscribe( + channel.getBytes(StandardCharsets.US_ASCII), + new RedisInvalidationTransport.Listener() { + @Override + public void onMessage(byte[] wireMessage) { + if (wireMessage == null) { + subscriber.onMalformedMessage(); + return; + } + codec + .decode(new String(wireMessage, StandardCharsets.US_ASCII)) + .ifPresentOrElse(subscriber::onMessage, subscriber::onMalformedMessage); + } + + @Override + public void onDisconnected() { + subscriber.onDisconnected(); + } + }); + return new RedisCacheInvalidationSubscription(delegate, subscriber); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + try { + delegate.close(); + } finally { + subscriber.onDisconnected(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheL2Region.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheL2Region.java new file mode 100644 index 0000000..e277f9e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheL2Region.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.cache.CacheRegionPort; + +/** + * Internal cache-only L2 surface needed by the local decorator. + * + *

Session, idempotency, rate-limit and coordination providers do not implement this type and + * therefore cannot accidentally receive the fail-open local tier. + */ +interface RedisCacheL2Region extends CacheRegionPort { + + /** Stable HMAC-derived identity; never the raw semantic key. */ + String localEntryIdentity(String key); + + /** Current region generation used to recover from missed best-effort invalidation hints. */ + String currentRegionGeneration(); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinator.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinator.java new file mode 100644 index 0000000..695821f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinator.java @@ -0,0 +1,295 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheRefreshClaimAttempt; +import dev.caskeleton.application.cache.CacheRefreshClaimOutcome; +import dev.caskeleton.application.cache.CacheRefreshCoordinationPort; +import dev.caskeleton.application.cache.CacheRefreshOperationToken; +import dev.caskeleton.application.cache.CacheRefreshOwnerToken; +import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** + * Redis-backed cache refresh admission lease. + * + *

The lease only suppresses duplicate refresh work. Cache generation/revision fences remain the + * correctness mechanism for invalidation races. + */ +final class RedisCacheRefreshCoordinator + implements CacheRefreshCoordinationPort, AutoCloseable { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final RedisKeyNamespace namespace; + private final byte[] hmacSecret; + private final RedisAtomicPrimitives primitives; + private final Supplier tokens; + private final RedisCapabilityObserver observer; + private final AtomicBoolean destroyed = new AtomicBoolean(); + + RedisCacheRefreshCoordinator( + RedisKeyNamespace namespace, byte[] hmacSecret, RedisBinaryCommands commands) { + this( + namespace, + hmacSecret, + productionPrimitives(commands), + RedisCacheRefreshCoordinator::randomToken, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisCacheRefreshCoordinator( + RedisKeyNamespace namespace, + byte[] hmacSecret, + RedisBinaryCommands commands, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { + this( + namespace, + hmacSecret, + productionPrimitives(commands), + RedisCacheRefreshCoordinator::randomToken, + observations, + ticker); + } + + RedisCacheRefreshCoordinator( + RedisKeyNamespace namespace, + byte[] hmacSecret, + RedisAtomicPrimitives primitives, + Supplier tokens) { + this( + namespace, + hmacSecret, + primitives, + tokens, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisCacheRefreshCoordinator( + RedisKeyNamespace namespace, + byte[] hmacSecret, + RedisAtomicPrimitives primitives, + Supplier tokens, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { + this.namespace = refreshNamespace(namespace); + Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"); + if (hmacSecret.length < 32) { + throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes"); + } + this.hmacSecret = hmacSecret.clone(); + this.primitives = Objects.requireNonNull(primitives, "primitives must be non-null"); + this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null"); + this.observer = new RedisCapabilityObserver(observations, ticker); + } + + @Override + public CacheRefreshClaimAttempt newAttempt() { + ensureUsable(); + return new CacheRefreshClaimAttempt( + new CacheRefreshOwnerToken(nextToken()), new CacheRefreshOperationToken(nextToken())); + } + + @Override + public CacheRefreshClaimOutcome claim( + String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.REFRESH_CLAIM, + () -> claimOpen(key, attempt, leaseTimeToLive), + RedisCacheRefreshCoordinator::classifyClaim); + } + + private CacheRefreshClaimOutcome claimOpen( + String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + ensureUsable(); + requireUsable(attempt); + try { + RedisAtomicPrimitives.RefreshClaimResult result = + primitives.claimRefreshLease( + physicalKey(key), + attempt.ownerToken().value(), + attempt.operationToken().value(), + leaseTimeToLive); + return switch (result) { + case CLAIMED -> new CacheRefreshClaimOutcome.Claimed(attempt); + case ALREADY_OWNED -> new CacheRefreshClaimOutcome.AlreadyOwned(attempt); + case CONTENDED -> new CacheRefreshClaimOutcome.Contended(); + case WRONG_TYPE, INVALID -> + throw new RedisProgramCompatibilityException( + RedisProgramId.CACHE_REFRESH_CLAIM, result.name()); + }; + } catch (RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? new CacheRefreshClaimOutcome.Unavailable() + : new CacheRefreshClaimOutcome.Indeterminate(); + } + } + + @Override + public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.REFRESH_RELEASE, + () -> releaseOpen(key, attempt), + RedisCacheRefreshCoordinator::classifyRelease); + } + + private CacheRefreshReleaseOutcome releaseOpen(String key, CacheRefreshClaimAttempt attempt) { + ensureUsable(); + requireUsable(attempt); + try { + RedisAtomicPrimitives.CompareDeleteResult result = + primitives.compareAndDelete(physicalKey(key), ownerState(attempt)); + return switch (result) { + case DELETED -> new CacheRefreshReleaseOutcome.Released(); + case ABSENT -> new CacheRefreshReleaseOutcome.AlreadyReleased(); + case NOT_OWNER -> new CacheRefreshReleaseOutcome.NotOwner(); + case WRONG_TYPE, INVALID -> + throw new RedisProgramCompatibilityException( + RedisProgramId.COMPARE_AND_DELETE, result.name()); + }; + } catch (RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? new CacheRefreshReleaseOutcome.Unavailable() + : new CacheRefreshReleaseOutcome.Indeterminate(); + } + } + + private String physicalKey(String semanticKey) { + if (semanticKey == null || semanticKey.isBlank()) { + throw new IllegalArgumentException("semantic cache key must be non-blank"); + } + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + namespace.hashKeyVersion(), + hmacSecret, + List.of(semanticKey.getBytes(StandardCharsets.UTF_8))); + return RedisKeyBuilder.build(namespace, digest); + } + + @Override + public void close() { + if (destroyed.compareAndSet(false, true)) { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + private void ensureUsable() { + if (destroyed.get()) { + throw new IllegalStateException("Redis cache refresh coordinator is destroyed"); + } + } + + private String nextToken() { + String token = Objects.requireNonNull(tokens.get(), "generated token must be non-null"); + if (!token.matches("[A-Za-z0-9_-]{16,63}")) { + throw new IllegalArgumentException( + "generated token must contain 16..63 Base64URL-safe characters"); + } + return token; + } + + private static byte[] ownerState(CacheRefreshClaimAttempt attempt) { + return (attempt.ownerToken().value() + "|" + attempt.operationToken().value()) + .getBytes(StandardCharsets.US_ASCII); + } + + private static void requireUsable(CacheRefreshClaimAttempt attempt) { + Objects.requireNonNull(attempt, "attempt must be non-null"); + if (!attempt.usable()) { + throw new IllegalArgumentException("Redis refresh coordination requires a usable attempt"); + } + } + + private static RedisKeyNamespace refreshNamespace(RedisKeyNamespace namespace) { + Objects.requireNonNull(namespace, "namespace must be non-null"); + return new RedisKeyNamespace( + namespace.application(), + namespace.environment(), + namespace.capability(), + namespace.region(), + namespace.hashKeyVersion(), + namespace.keyVersion(), + "refresh-lease", + namespace.maximumKeyBytes()); + } + + private static RedisAtomicPrimitives productionPrimitives(RedisBinaryCommands commands) { + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + return new RedisAtomicPrimitives(catalog, new RedisLuaProgramExecutor(catalog, commands)); + } + + private static String randomToken() { + byte[] random = new byte[16]; + RANDOM.nextBytes(random); + return Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + private static RedisCapabilityObserver.Classification classifyClaim( + CacheRefreshClaimOutcome outcome) { + if (outcome instanceof CacheRefreshClaimOutcome.Claimed + || outcome instanceof CacheRefreshClaimOutcome.AlreadyOwned) { + return classification( + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + if (outcome instanceof CacheRefreshClaimOutcome.Contended) { + return classification( + RedisCapabilityObservationEvent.Outcome.CONTENDED, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + if (outcome instanceof CacheRefreshClaimOutcome.Indeterminate) { + return classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + return classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static RedisCapabilityObserver.Classification classifyRelease( + CacheRefreshReleaseOutcome outcome) { + if (outcome instanceof CacheRefreshReleaseOutcome.Released + || outcome instanceof CacheRefreshReleaseOutcome.AlreadyReleased) { + return classification( + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + if (outcome instanceof CacheRefreshReleaseOutcome.NotOwner) { + return classification( + RedisCapabilityObservationEvent.Outcome.CONFLICT, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + if (outcome instanceof CacheRefreshReleaseOutcome.Indeterminate) { + return classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + return classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static RedisCapabilityObserver.Classification classification( + RedisCapabilityObservationEvent.Outcome outcome, + RedisCapabilityObservationEvent.Certainty certainty) { + return new RedisCapabilityObserver.Classification(outcome, certainty); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java index edc02f5..818d0cc 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionPolicy.java @@ -1,32 +1,94 @@ package dev.caskeleton.adapter.outbound.cache.redis; import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.util.Arrays; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; /** Immutable key, TTL and envelope bounds for one semantic string cache region. */ -final class RedisCacheRegionPolicy { +final class RedisCacheRegionPolicy implements AutoCloseable { + + private static final Duration MAXIMUM_TTL = Duration.ofDays(30); + private static final Duration COMPATIBILITY_MINIMUM_HARD_TTL = Duration.ofMillis(1); private final RedisKeyNamespace namespace; private final byte[] hmacSecret; - private final Duration positiveTtl; + private final String policyRevision; + private final Duration positiveSoftTtl; + private final Duration positiveHardTtl; private final Duration negativeTtl; + private final double jitterRatio; + private final Duration minimumHardTtl; private final int maximumValueBytes; + private final AtomicBoolean destroyed = new AtomicBoolean(); + /** + * Compatibility constructor for the existing single-positive-TTL settings contract. + * + *

It deliberately disables stale serving and jitter. New region bindings should use the full + * constructor so the effective policy revision and soft/hard bounds are explicit. + */ RedisCacheRegionPolicy( RedisKeyNamespace namespace, byte[] hmacSecret, Duration positiveTtl, Duration negativeTtl, int maximumValueBytes) { + this( + namespace, + hmacSecret, + "single-ttl-compatibility-r1", + positiveTtl, + positiveTtl, + negativeTtl, + 0.0, + COMPATIBILITY_MINIMUM_HARD_TTL, + maximumValueBytes); + } + + RedisCacheRegionPolicy( + RedisKeyNamespace namespace, + byte[] hmacSecret, + String policyRevision, + Duration positiveSoftTtl, + Duration positiveHardTtl, + Duration negativeTtl, + double jitterRatio, + Duration minimumHardTtl, + int maximumValueBytes) { this.namespace = Objects.requireNonNull(namespace, "namespace must be non-null"); Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"); if (hmacSecret.length < 32) { throw new IllegalArgumentException("hmacSecret must contain at least 32 bytes"); } this.hmacSecret = hmacSecret.clone(); - this.positiveTtl = positive(positiveTtl, "positiveTtl"); + this.policyRevision = policyRevision(policyRevision); + this.positiveSoftTtl = positive(positiveSoftTtl, "positiveSoftTtl"); + this.positiveHardTtl = positive(positiveHardTtl, "positiveHardTtl"); this.negativeTtl = positive(negativeTtl, "negativeTtl"); + if (this.positiveSoftTtl.compareTo(this.positiveHardTtl) > 0) { + throw new IllegalArgumentException("positive soft TTL must not exceed positive hard TTL"); + } + if (!Double.isFinite(jitterRatio) || jitterRatio < 0.0 || jitterRatio > 0.5) { + throw new IllegalArgumentException("jitter ratio must be finite and in 0.0..0.5"); + } + this.jitterRatio = jitterRatio; + if (scale(this.positiveHardTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0 + || scale(this.negativeTtl, 1.0 + jitterRatio).compareTo(MAXIMUM_TTL) > 0) { + throw new IllegalArgumentException( + "configured hard TTL plus positive jitter must not exceed 30 days"); + } + this.minimumHardTtl = positive(minimumHardTtl, "minimumHardTtl"); + if (this.minimumHardTtl.compareTo(this.positiveHardTtl) > 0 + || this.minimumHardTtl.compareTo(this.negativeTtl) > 0) { + throw new IllegalArgumentException( + "minimum hard TTL must not exceed positive hard TTL or negative TTL"); + } if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) { throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216"); } @@ -38,26 +100,151 @@ final class RedisCacheRegionPolicy { } byte[] hmacSecret() { + ensureUsable(); return hmacSecret.clone(); } + String policyRevision() { + return policyRevision; + } + + Duration positiveSoftTtl() { + return positiveSoftTtl; + } + + Duration positiveHardTtl() { + return positiveHardTtl; + } + + /** Existing accessor retained while single-TTL runtime settings migrate to the full policy. */ Duration positiveTtl() { - return positiveTtl; + return positiveHardTtl; } Duration negativeTtl() { return negativeTtl; } + Duration maximumEntryTimeToLive() { + Duration maximumConfigured = + positiveHardTtl.compareTo(negativeTtl) >= 0 ? positiveHardTtl : negativeTtl; + return scale(maximumConfigured, 1.0 + jitterRatio); + } + int maximumValueBytes() { return maximumValueBytes; } + @Override + public void close() { + if (destroyed.compareAndSet(false, true)) { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + PositiveExpiry positiveExpiry(byte[] hmacDerivedPhysicalKey) { + double factor = effectiveFactor(hmacDerivedPhysicalKey, "positive", positiveHardTtl); + Duration soft = scale(positiveSoftTtl, factor); + Duration hard = scale(positiveHardTtl, factor); + if (hard.compareTo(minimumHardTtl) < 0) { + hard = minimumHardTtl; + } + if (soft.compareTo(hard) > 0) { + soft = hard; + } + return new PositiveExpiry(soft, hard); + } + + Duration negativeTimeToLive(byte[] hmacDerivedPhysicalKey) { + double factor = effectiveFactor(hmacDerivedPhysicalKey, "negative", negativeTtl); + Duration actual = scale(negativeTtl, factor); + return actual.compareTo(minimumHardTtl) < 0 ? minimumHardTtl : actual; + } + + private double effectiveFactor( + byte[] hmacDerivedPhysicalKey, String expiryKind, Duration configuredHardTtl) { + Objects.requireNonNull(hmacDerivedPhysicalKey, "hmacDerivedPhysicalKey must be non-null"); + if (hmacDerivedPhysicalKey.length == 0) { + throw new IllegalArgumentException("hmacDerivedPhysicalKey must not be empty"); + } + double sampledFactor = + 1.0 + (jitterRatio * symmetricSample(hmacDerivedPhysicalKey, expiryKind)); + double minimumFactor = + ((double) minimumHardTtl.toMillis()) / Math.max(1L, configuredHardTtl.toMillis()); + return Math.max(sampledFactor, minimumFactor); + } + + private double symmetricSample(byte[] hmacDerivedPhysicalKey, String expiryKind) { + byte[] kind = expiryKind.getBytes(StandardCharsets.UTF_8); + byte[] revision = policyRevision.getBytes(StandardCharsets.UTF_8); + ByteBuffer canonical = + ByteBuffer.allocate( + Integer.BYTES + + hmacDerivedPhysicalKey.length + + Integer.BYTES + + revision.length + + Integer.BYTES + + kind.length); + canonical + .putInt(hmacDerivedPhysicalKey.length) + .put(hmacDerivedPhysicalKey) + .putInt(revision.length) + .put(revision) + .putInt(kind.length) + .put(kind); + long sampleBits = ByteBuffer.wrap(sha256(canonical.array())).getLong() >>> 11; + double unitInterval = sampleBits * 0x1.0p-53; + return (unitInterval * 2.0) - 1.0; + } + + private static Duration scale(Duration configured, double factor) { + long configuredMillis = Math.max(1L, configured.toMillis()); + long actualMillis = Math.max(1L, Math.round(configuredMillis * factor)); + return Duration.ofMillis(actualMillis); + } + private static Duration positive(Duration value, String field) { Objects.requireNonNull(value, field + " must be non-null"); - if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) { + if (value.isZero() || value.isNegative() || value.compareTo(MAXIMUM_TTL) > 0) { throw new IllegalArgumentException(field + " must be positive and at most 30 days"); } return value; } + + private static String policyRevision(String value) { + Objects.requireNonNull(value, "policyRevision must be non-null"); + if (value.isBlank() || value.length() > 128) { + throw new IllegalArgumentException("policyRevision must contain 1..128 characters"); + } + return value; + } + + private static byte[] sha256(byte[] content) { + try { + return MessageDigest.getInstance("SHA-256").digest(content); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable for cache TTL jitter", exception); + } + } + + private void ensureUsable() { + if (destroyed.get()) { + throw new IllegalStateException("Redis cache region policy is destroyed"); + } + } + + record PositiveExpiry(Duration softTtl, Duration hardTtl) { + + PositiveExpiry { + Objects.requireNonNull(softTtl, "softTtl must be non-null"); + Objects.requireNonNull(hardTtl, "hardTtl must be non-null"); + if (softTtl.isZero() + || softTtl.isNegative() + || hardTtl.isZero() + || hardTtl.isNegative() + || softTtl.compareTo(hardTtl) > 0) { + throw new IllegalArgumentException("positive expiry requires 0 < softTtl <= hardTtl"); + } + } + } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionRuntime.java new file mode 100644 index 0000000..cfa1e94 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRegionRuntime.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.cache.CacheRegionPort; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Lifecycle-owning composition of the Redis L2 and its optional cache-only local decorator. */ +final class RedisCacheRegionRuntime implements CacheRegionPort, AutoCloseable { + + private final CacheRegionPort delegate; + private final RedisCacheL2Region l2; + private final RedisLocalCacheRegion local; + private final AtomicBoolean closed = new AtomicBoolean(); + + private RedisCacheRegionRuntime( + CacheRegionPort delegate, + RedisCacheL2Region l2, + RedisLocalCacheRegion local) { + this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null"); + this.l2 = Objects.requireNonNull(l2, "l2 must be non-null"); + this.local = local; + } + + static RedisCacheRegionRuntime l2Only(RedisCacheL2Region l2) { + return new RedisCacheRegionRuntime(l2, l2, null); + } + + static RedisCacheRegionRuntime local(RedisCacheL2Region l2, RedisLocalCacheRegion local) { + return new RedisCacheRegionRuntime( + Objects.requireNonNull(local, "local must be non-null"), l2, local); + } + + Optional local() { + return Optional.ofNullable(local); + } + + @Override + public CacheLookup lookup(String key) { + ensureOpen(); + return delegate.lookup(key); + } + + @Override + public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) { + ensureOpen(); + return delegate.record(key, value, metadata); + } + + @Override + public CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + ensureOpen(); + return delegate.recordAbsent(key, reason, metadata); + } + + @Override + public CacheInvalidationOutcome invalidate(String key) { + ensureOpen(); + return delegate.invalidate(key); + } + + @Override + public CacheInvalidationOutcome invalidateRegion() { + ensureOpen(); + return delegate.invalidateRegion(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + RuntimeException failure = null; + try { + if (local != null) { + local.close(); + } + } catch (RuntimeException exception) { + failure = exception; + } + if (l2 instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception exception) { + RuntimeException closeFailure = + exception instanceof RuntimeException runtimeException + ? runtimeException + : new IllegalStateException("Redis cache L2 close failed", exception); + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Redis cache region runtime is closed"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalActivationValidator.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalActivationValidator.java new file mode 100644 index 0000000..42f6d27 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalActivationValidator.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Rejects ambiguous canonical/legacy activation and unapproved legacy production primaries. */ +final class RedisCanonicalActivationValidator { + + private RedisCanonicalActivationValidator() {} + + static void validate( + boolean canonicalActive, + boolean legacyMigrationEnabled, + boolean legacyCacheEnabled, + boolean legacyRateLimitEnabled) { + boolean legacyActive = legacyCacheEnabled || legacyRateLimitEnabled; + if (canonicalActive && legacyActive) { + throw new IllegalStateException( + "Canonical and legacy Redis configuration cannot be active simultaneously; no precedence" + + " is defined"); + } + if (legacyActive && !legacyMigrationEnabled) { + throw new IllegalStateException( + "Legacy standalone Redis activation requires explicit migration input mode"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfig.java new file mode 100644 index 0000000..5e4b54b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfig.java @@ -0,0 +1,159 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.application.cache.CacheObservationPort; +import dev.caskeleton.application.cache.DisabledCacheObservationPort; +import io.micrometer.core.instrument.MeterRegistry; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.Set; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Canonical default-region cache composition, isolated to the physical Redis CACHE role. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties({RedisCanonicalCacheSettings.class, RedisProviderSettings.class}) +@ConditionalOnProperty( + name = "ca-skeleton.capabilities.cache.bindings.default", + havingValue = "redis", + matchIfMissing = false) +public class RedisCanonicalCacheConfig { + + private static final int MAXIMUM_COMMAND_OVERHEAD_BYTES = 4096; + + @Bean(name = "redisCanonicalDefaultCacheRegion", destroyMethod = "close") + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.cache.bindings.default", + havingValue = "redis", + matchIfMissing = false) + RedisCacheRegionRuntime redisCanonicalDefaultCacheRegion( + RedisCanonicalCacheSettings settings, + RedisProviderSettings providerProperties, + RedisCanonicalRoleRegistry roleRegistry, + RedisCredentialMaterialProvider credentialProvider, + ObjectProvider clockProvider, + ObjectProvider meterRegistryProvider, + ObjectProvider capabilityObservationsProvider) { + settings.validateActive(); + validateCommandBound(settings, providerProperties.runtime()); + Clock clock = clockProvider.getIfAvailable(Clock::systemUTC); + RedisCapabilityObservationPort capabilityObservations = + capabilityObservationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance); + RedisRoleCommandRouter router = roleRegistry.router(RedisRole.CACHE); + byte[] hmacSecret = + RedisHmacMaterialResolver.resolve( + settings.keyHmacSecretReference(), credentialProvider, clock, "cache"); + RedisCacheRegionPolicy policy = null; + RedisStringCacheRegion l2 = null; + RedisCacheInvalidationMessage.Codec codec = null; + try { + policy = + new RedisCacheRegionPolicy( + settings.namespace(), + hmacSecret, + settings.policyRevision(), + settings.positiveSoftTtl(), + settings.positiveHardTtl(), + settings.negativeTtl(), + settings.ttlJitter(), + minimumHardTtl(settings), + settings.maximumValueBytes()); + l2 = + new RedisStringCacheRegion( + policy, router, clock, capabilityObservations, System::nanoTime); + policy = null; + if (!settings.l1().enabled()) { + RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.l2Only(l2); + l2 = null; + return runtime; + } + + MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable(); + CacheObservationPort observations = + meterRegistry == null + ? DisabledCacheObservationPort.instance() + : new MicrometerCacheObservationPort( + meterRegistry, Set.of(settings.semanticRegion())); + String channel = l2.invalidationChannel(); + codec = new RedisCacheInvalidationMessage.Codec(hmacSecret); + RedisLocalCacheRegion local = + new RedisLocalCacheRegion( + settings.semanticRegion(), + l2, + settings.l1().policy(), + clock, + observations, + channel, + codec, + message -> + router.publish( + channel.getBytes(StandardCharsets.US_ASCII), + message.getBytes(StandardCharsets.US_ASCII))); + RedisCacheRegionRuntime runtime = RedisCacheRegionRuntime.local(l2, local); + l2 = null; + codec = null; + return runtime; + } finally { + Arrays.fill(hmacSecret, (byte) 0); + if (codec != null) { + codec.close(); + } + if (l2 != null) { + l2.close(); + } + if (policy != null) { + policy.close(); + } + } + } + + @Bean(name = "redisCanonicalDefaultCacheInvalidationSubscription", destroyMethod = "close") + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.cache.regions.default.l1.enabled", + havingValue = "true", + matchIfMissing = false) + RedisCacheInvalidationSubscription redisCanonicalDefaultCacheInvalidationSubscription( + RedisCanonicalRoleRegistry roleRegistry, + @Qualifier("redisCanonicalDefaultCacheRegion") RedisCacheRegionRuntime cacheRegion) { + RedisLocalCacheRegion local = + cacheRegion + .local() + .orElseThrow( + () -> + new IllegalStateException( + "Canonical Redis L1 subscription requires the cache-only local decorator")); + return RedisCacheInvalidationSubscription.subscribe( + roleRegistry.router(RedisRole.CACHE), + local.invalidationChannel(), + local.invalidationMessageCodec(), + local.invalidationSubscriber()); + } + + private static void validateCommandBound( + RedisCanonicalCacheSettings settings, RedisProviderSettings.RuntimeProperties runtime) { + long required = (long) settings.maximumValueBytes() + MAXIMUM_COMMAND_OVERHEAD_BYTES; + if (required > runtime.maximumCommandBytes()) { + throw new IllegalArgumentException( + "Canonical Redis cache maximum value bytes exceed the CACHE router command bound"); + } + } + + private static Duration minimumHardTtl(RedisCanonicalCacheSettings settings) { + Duration minimum = Duration.ofSeconds(1); + if (minimum.compareTo(settings.positiveHardTtl()) > 0 + || minimum.compareTo(settings.negativeTtl()) > 0) { + return settings.positiveHardTtl().compareTo(settings.negativeTtl()) <= 0 + ? settings.positiveHardTtl() + : settings.negativeTtl(); + } + return minimum; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettings.java new file mode 100644 index 0000000..7905043 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettings.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** + * Canonical policy for the skeleton's default semantic Redis cache region. + * + *

Provider connection and authentication settings intentionally do not exist here. The CACHE + * role binding owns the topology router, while this capability policy owns only semantic cache + * behavior and a reference to HMAC key material. + */ +@ConfigurationProperties(prefix = "ca-skeleton.capabilities.cache.regions.default") +public record RedisCanonicalCacheSettings( + String keyHmacSecretReference, + String namespaceApplication, + String namespaceEnvironment, + String semanticRegion, + int hashKeyVersion, + int keyVersion, + String policyRevision, + Duration positiveSoftTtl, + Duration positiveHardTtl, + Duration negativeTtl, + Double ttlJitter, + int maximumValueBytes, + LocalProperties l1) { + + private static final Duration MAXIMUM_TTL = Duration.ofDays(30); + + @ConstructorBinding + public RedisCanonicalCacheSettings { + keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim(); + namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); + namespaceEnvironment = defaultText(namespaceEnvironment, "local"); + semanticRegion = defaultText(semanticRegion, "default"); + hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion; + keyVersion = keyVersion == 0 ? 1 : keyVersion; + policyRevision = defaultText(policyRevision, "canonical-default-r1"); + positiveHardTtl = + positive(positiveHardTtl, Duration.ofMinutes(5), MAXIMUM_TTL, "positiveHardTtl"); + positiveSoftTtl = + positive( + positiveSoftTtl, + positiveHardTtl.multipliedBy(4).dividedBy(5), + MAXIMUM_TTL, + "positiveSoftTtl"); + negativeTtl = positive(negativeTtl, Duration.ofMinutes(1), MAXIMUM_TTL, "negativeTtl"); + ttlJitter = ttlJitter == null ? 0.10d : ttlJitter; + maximumValueBytes = maximumValueBytes == 0 ? 61_440 : maximumValueBytes; + l1 = l1 == null ? LocalProperties.defaults() : l1; + + if (positiveSoftTtl.compareTo(positiveHardTtl) > 0) { + throw new IllegalArgumentException("positiveSoftTtl must not exceed positiveHardTtl"); + } + if (!Double.isFinite(ttlJitter) || ttlJitter < 0.0d || ttlJitter > 0.5d) { + throw new IllegalArgumentException("ttlJitter must be in 0.0..0.5"); + } + if (policyRevision.length() > 128 || policyRevision.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("policyRevision must contain 1..128 safe characters"); + } + if (maximumValueBytes < 1 || maximumValueBytes > 16_777_216) { + throw new IllegalArgumentException("maximumValueBytes must be in 1..16777216"); + } + // Centralizes slug and key-version validation without retaining a duplicate rule set. + new RedisKeyNamespace( + namespaceApplication, + namespaceEnvironment, + "cache", + semanticRegion, + hashKeyVersion, + keyVersion, + "entry", + 512); + } + + void validateActive() { + RedisSecretReference.parse(keyHmacSecretReference); + } + + RedisKeyNamespace namespace() { + return new RedisKeyNamespace( + namespaceApplication, + namespaceEnvironment, + "cache", + semanticRegion, + hashKeyVersion, + keyVersion, + "entry", + 512); + } + + public record LocalProperties( + boolean enabled, + int maximumEntries, + long maximumWeightBytes, + long maximumEntryWeightBytes, + Duration timeToLive, + Duration generationRecheckInterval, + int invalidationQueueCapacity) { + + @ConstructorBinding + public LocalProperties { + maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries; + maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864L : maximumWeightBytes; + maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576L : maximumEntryWeightBytes; + timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive; + generationRecheckInterval = + generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval; + invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity; + policy( + maximumEntries, + maximumWeightBytes, + maximumEntryWeightBytes, + timeToLive, + generationRecheckInterval, + invalidationQueueCapacity); + } + + RedisLocalCachePolicy policy() { + return policy( + maximumEntries, + maximumWeightBytes, + maximumEntryWeightBytes, + timeToLive, + generationRecheckInterval, + invalidationQueueCapacity); + } + + private static LocalProperties defaults() { + return new LocalProperties(false, 0, 0, 0, null, null, 0); + } + + private static RedisLocalCachePolicy policy( + int maximumEntries, + long maximumWeightBytes, + long maximumEntryWeightBytes, + Duration timeToLive, + Duration generationRecheckInterval, + int invalidationQueueCapacity) { + return new RedisLocalCachePolicy( + maximumEntries, + maximumWeightBytes, + maximumEntryWeightBytes, + timeToLive, + generationRecheckInterval, + invalidationQueueCapacity); + } + } + + private static Duration positive( + Duration value, Duration fallback, Duration maximum, String field) { + Duration actual = value == null ? fallback : value; + if (actual.isZero() || actual.isNegative() || actual.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + return actual; + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfig.java new file mode 100644 index 0000000..18ffe38 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfig.java @@ -0,0 +1,181 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettingsFactory; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.Clock; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Canonical Redis composition root. + * + *

Provider definitions alone are inert. Only an explicit role binding resolves material and + * opens a topology-native client. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(RedisProviderSettings.class) +public class RedisCanonicalConfig { + + @Bean + RedisCapabilityObservationPort redisCapabilityObservationPort( + ObjectProvider meterRegistryProvider) { + MeterRegistry registry = meterRegistryProvider.getIfAvailable(); + RedisCapabilityObservationPort delegate = + registry == null + ? NoOpRedisCapabilityObservationPort.instance() + : new MicrometerRedisCapabilityObservationPort(registry); + return new SafeRedisCapabilityObservationPort(delegate); + } + + @Bean(name = "redisCanonicalRoleRegistry", destroyMethod = "close") + RedisCanonicalRoleRegistry redisCanonicalRoleRegistry( + RedisProviderSettings properties, + Environment environment, + ObjectProvider credentialProvider, + ObjectProvider trustProvider, + ObjectProvider clockProvider, + ObjectProvider connectorProvider, + ObjectProvider sentinelConnectorProvider, + RedisCapabilityObservationPort observations) { + Map> selectedCapabilities = + selectedCapabilities(environment); + Map + active = + new RedisDeploymentSettingsFactory() + .compileActive(properties, selectedRoles(selectedCapabilities)); + RedisCanonicalActivationValidator.validate( + !active.isEmpty(), + properties.legacyMigrationEnabled(), + environment.getProperty("app.cache.redis.enabled", Boolean.class, false), + environment.getProperty("app.rate-limit.legacy-standalone-enabled", Boolean.class, false)); + + RedisProviderSettings.RuntimeProperties runtime = properties.runtime(); + Clock clock = clockProvider.getIfAvailable(Clock::systemUTC); + RedisRuntimeConnector connector = + connectorProvider.getIfAvailable( + () -> + deployment -> + connect( + deployment, + runtime, + requiredUnique(credentialProvider, "Redis credential material provider"), + requiredUnique(trustProvider, "Redis trust material provider"), + clock)); + RedisSentinelRuntimeConnector sentinelConnector = + active.values().stream().anyMatch(RedisDeploymentSettings.Sentinel.class::isInstance) + ? sentinelConnectorProvider.getIfAvailable( + () -> + new DefaultRedisSentinelRuntimeConnector( + runtime.clientSettings(), + runtime.maximumCommandBytes(), + requiredUnique(credentialProvider, "Redis credential material provider"), + requiredUnique(trustProvider, "Redis trust material provider"), + clock)) + : null; + return new RedisCanonicalRoleRegistry( + active, + runtime.clientSettings(), + runtime.maximumInFlightCommands(), + runtime.maximumCommandBytes(), + runtime.maximumInFlightBytes(), + runtime.routeDrainTimeout(), + runtime.defaultWriteTtl(), + connector::connect, + properties.roles(), + selectedCapabilities, + clock, + runtime.semanticProbeMinimumInterval(), + runtime.semanticProbeMaximumStaleness(), + System::nanoTime, + observations, + sentinelConnector, + runtime.sentinelDiscoveryRefreshPeriod(), + BoundedRedisSentinelRefreshWorker::new); + } + + private static RedisRoutableCommandRuntime connect( + RedisDeploymentSettings deployment, + RedisProviderSettings.RuntimeProperties runtime, + RedisCredentialMaterialProvider credentialProvider, + RedisTrustMaterialProvider trustProvider, + Clock clock) { + return RedisTopologyCommandRuntime.connect( + deployment, + runtime.clientSettings(), + runtime.maximumCommandBytes(), + credentialProvider, + trustProvider, + clock); + } + + private static T requiredUnique(ObjectProvider provider, String capability) { + T instance = provider.getIfUnique(); + if (instance == null) { + throw new IllegalStateException( + capability + " must have exactly one bean for a canonically bound Redis role"); + } + return instance; + } + + public static Map> selectedCapabilities( + Environment environment) { + Map> selected = + new EnumMap<>(RedisRole.class); + EnumSet cache = + EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class); + if (selected(environment, "ca-skeleton.capabilities.cache.bindings.default", "redis")) { + cache.add(RedisHealthSnapshotProvider.Capability.CACHE); + } + selected.put(RedisRole.CACHE, Set.copyOf(cache)); + + EnumSet coordination = + EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class); + if (selected(environment, "ca-skeleton.capabilities.rate-limit.provider", "redis")) { + coordination.add(RedisHealthSnapshotProvider.Capability.RATE_LIMIT); + } + if (selected(environment, "ca-skeleton.capabilities.idempotency.provider", "redis")) { + coordination.add(RedisHealthSnapshotProvider.Capability.IDEMPOTENCY); + } + if (selected(environment, "ca-skeleton.capabilities.lease.provider", "redis")) { + coordination.add(RedisHealthSnapshotProvider.Capability.EFFICIENCY_LEASE); + } + selected.put(RedisRole.COORDINATION, Set.copyOf(coordination)); + + EnumSet session = + EnumSet.noneOf(RedisHealthSnapshotProvider.Capability.class); + if (selected(environment, "ca-skeleton.security.auth-mode", "redis-session")) { + session.add(RedisHealthSnapshotProvider.Capability.SESSION); + } + selected.put(RedisRole.SESSION, Set.copyOf(session)); + return Map.copyOf(selected); + } + + private static Set selectedRoles( + Map> capabilities) { + EnumSet roles = EnumSet.noneOf(RedisRole.class); + capabilities.forEach( + (role, selectedCapabilities) -> { + if (!selectedCapabilities.isEmpty()) { + roles.add(role); + } + }); + return Set.copyOf(roles); + } + + private static boolean selected(Environment environment, String property, String expected) { + return expected.equalsIgnoreCase(environment.getProperty(property, "")); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistry.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistry.java new file mode 100644 index 0000000..a4f3a0f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistry.java @@ -0,0 +1,758 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; + +/** Owns exactly the command routers selected by canonical Redis role bindings. */ +final class RedisCanonicalRoleRegistry implements AutoCloseable, RedisHealthSnapshotProvider { + + private static final Duration SENTINEL_CLEANUP_COMPLETION_MARGIN = Duration.ofMillis(100); + + @FunctionalInterface + interface RuntimeFactory { + + RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment); + } + + private final Map routers; + private final Map observations; + private final Map bindings; + private final Map> capabilities; + private final Map probePlans; + private final Map recoveries; + private final RedisSemanticReadinessProbe semanticProbe; + private final RuntimeFactory runtimeFactory; + private final Clock clock; + private final Duration probeTimeout; + private final Duration drainTimeout; + private final int maximumInFlight; + private final int maximumCommandBytes; + private final long maximumInFlightBytes; + private final Duration defaultWriteTtl; + private final LongSupplier ticker; + private final RedisCapabilityObservationPort observationsPort; + private final RedisSentinelFailoverCoordinator failoverCoordinator; + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisCanonicalRoleRegistry( + Map activeDeployments, + RedisClientRuntimeSettings clientSettings, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration drainTimeout, + Duration defaultWriteTtl, + RuntimeFactory runtimeFactory) { + this( + activeDeployments, + clientSettings, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + drainTimeout, + defaultWriteTtl, + runtimeFactory, + Map.of(), + Map.of(), + Clock.systemUTC()); + } + + RedisCanonicalRoleRegistry( + Map activeDeployments, + RedisClientRuntimeSettings clientSettings, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration drainTimeout, + Duration defaultWriteTtl, + RuntimeFactory runtimeFactory, + Map bindings, + Map> capabilities, + Clock clock) { + this( + activeDeployments, + clientSettings, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + drainTimeout, + defaultWriteTtl, + runtimeFactory, + bindings, + capabilities, + clock, + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance()); + } + + RedisCanonicalRoleRegistry( + Map activeDeployments, + RedisClientRuntimeSettings clientSettings, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration drainTimeout, + Duration defaultWriteTtl, + RuntimeFactory runtimeFactory, + Map bindings, + Map> capabilities, + Clock clock, + Duration semanticProbeMinimumInterval, + Duration semanticProbeMaximumStaleness, + LongSupplier ticker) { + this( + activeDeployments, + clientSettings, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + drainTimeout, + defaultWriteTtl, + runtimeFactory, + bindings, + capabilities, + clock, + semanticProbeMinimumInterval, + semanticProbeMaximumStaleness, + ticker, + NoOpRedisCapabilityObservationPort.instance(), + null, + Duration.ofSeconds(30), + BoundedRedisSentinelRefreshWorker::new); + } + + RedisCanonicalRoleRegistry( + Map activeDeployments, + RedisClientRuntimeSettings clientSettings, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration drainTimeout, + Duration defaultWriteTtl, + RuntimeFactory runtimeFactory, + Map bindings, + Map> capabilities, + Clock clock, + Duration semanticProbeMinimumInterval, + Duration semanticProbeMaximumStaleness, + LongSupplier ticker, + RedisCapabilityObservationPort observationsPort) { + this( + activeDeployments, + clientSettings, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + drainTimeout, + defaultWriteTtl, + runtimeFactory, + bindings, + capabilities, + clock, + semanticProbeMinimumInterval, + semanticProbeMaximumStaleness, + ticker, + observationsPort, + null, + Duration.ofSeconds(30), + BoundedRedisSentinelRefreshWorker::new); + } + + RedisCanonicalRoleRegistry( + Map activeDeployments, + RedisClientRuntimeSettings clientSettings, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration drainTimeout, + Duration defaultWriteTtl, + RuntimeFactory runtimeFactory, + Map bindings, + Map> capabilities, + Clock clock, + Duration semanticProbeMinimumInterval, + Duration semanticProbeMaximumStaleness, + LongSupplier ticker, + RedisCapabilityObservationPort observationsPort, + RedisSentinelRuntimeConnector sentinelConnector, + Duration sentinelDiscoveryRefreshPeriod, + RedisSentinelFailoverCoordinator.WorkerFactory workerFactory) { + Objects.requireNonNull(ticker, "ticker must be non-null"); + Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null"); + Objects.requireNonNull(clientSettings, "clientSettings must be non-null"); + Objects.requireNonNull(runtimeFactory, "runtimeFactory must be non-null"); + Objects.requireNonNull(bindings, "bindings must be non-null"); + Map activeBindings = new EnumMap<>(RedisRole.class); + activeDeployments.forEach( + (role, ignored) -> { + RedisRoleBinding binding = bindings.get(role); + if (binding != null) { + activeBindings.put(role, binding); + } + }); + this.bindings = Map.copyOf(activeBindings); + Map> safeCapabilities = new EnumMap<>(RedisRole.class); + Objects.requireNonNull(capabilities, "capabilities must be non-null") + .forEach((role, values) -> safeCapabilities.put(role, Set.copyOf(values))); + this.capabilities = Map.copyOf(safeCapabilities); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.runtimeFactory = runtimeFactory; + this.ticker = ticker; + this.observationsPort = + new SafeRedisCapabilityObservationPort( + Objects.requireNonNull(observationsPort, "observationsPort must be non-null")); + this.semanticProbe = RedisSemanticReadinessProbe.system(this.clock); + Map plans = new EnumMap<>(RedisRole.class); + activeBindings.forEach( + (role, ignored) -> + plans.put( + role, + RedisSemanticProbePlan.forRole( + role, this.capabilities.getOrDefault(role, Set.of())))); + this.probePlans = Map.copyOf(plans); + this.probeTimeout = clientSettings.commandTimeout(); + this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout must be non-null"); + this.maximumInFlight = maximumInFlight; + this.maximumCommandBytes = maximumCommandBytes; + this.maximumInFlightBytes = maximumInFlightBytes; + this.defaultWriteTtl = + Objects.requireNonNull(defaultWriteTtl, "defaultWriteTtl must be non-null"); + if (drainTimeout.compareTo(clientSettings.overallTimeout().plusMillis(100)) < 0) { + throw new IllegalArgumentException( + "Redis route drain timeout must include the runtime overall timeout and a 100ms safety" + + " margin"); + } + + activeDeployments.forEach(RedisCanonicalRoleRegistry::rejectUnsupportedTopology); + Map sentinelDeployments = + sentinelDeployments(activeDeployments); + if (!sentinelDeployments.isEmpty() && sentinelConnector == null) { + throw new IllegalStateException( + "Redis Sentinel refresh connector is required for every active Sentinel role"); + } + Map created = new EnumMap<>(RedisRole.class); + Map createdObservations = + new EnumMap<>(RedisRole.class); + Map createdRecoveries = new EnumMap<>(RedisRole.class); + AtomicReference coordinatorReference = + new AtomicReference<>(); + RedisSentinelFailoverCoordinator createdCoordinator = null; + try { + activeDeployments.forEach( + (role, deployment) -> { + RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener = + deployment instanceof RedisDeploymentSettings.Sentinel + ? (failedRoute, failure) -> { + RedisSentinelFailoverCoordinator coordinator = coordinatorReference.get(); + if (coordinator != null) { + coordinator.requestRecovery(role, failedRoute); + } + } + : RedisRoleCommandRouter.TopologyFailureListener.ignore(); + RedisRoutableCommandRuntime runtime; + try { + runtime = + deployment instanceof RedisDeploymentSettings.Sentinel sentinel + ? connectSentinel(sentinelConnector, sentinel) + : runtimeFactory.connect(deployment); + } catch (RedisTemporaryConnectionException temporary) { + if (!isOptionalCache(role)) { + throw temporary; + } + installDormant( + role, + deployment, + created, + createdObservations, + createdRecoveries, + semanticProbeMinimumInterval, + semanticProbeMaximumStaleness, + ticker, + topologyFailureListener); + return; + } + RedisRoleCommandRouter router = newRouter(role, runtime, topologyFailureListener); + try { + RedisSemanticProbePlan plan = probePlans.get(role); + if (plan == null) { + router.probe(probeTimeout); + } else { + RedisSemanticReadinessProbe.Result qualification = + semanticProbe.probeResult(plan, router); + if (qualification.disposition() + == RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT + && isOptionalCache(role)) { + router.close(); + installDormant( + role, + deployment, + created, + createdObservations, + createdRecoveries, + semanticProbeMinimumInterval, + semanticProbeMaximumStaleness, + ticker, + topologyFailureListener); + return; + } + if (qualification.disposition() + != RedisSemanticReadinessProbe.Disposition.SUCCEEDED) { + throw new IllegalStateException( + "Redis semantic qualification failed: " + qualification.reason().name()); + } + RedisSemanticProbeObservationCache observation = + new RedisSemanticProbeObservationCache( + semanticProbeMinimumInterval, + semanticProbeMaximumStaleness, + this.clock, + ticker); + observation.seed(qualification.reason()); + createdObservations.put(role, observation); + } + created.put(role, router); + } catch (RuntimeException exception) { + router.close(); + throw exception; + } + }); + if (!sentinelDeployments.isEmpty()) { + createdCoordinator = + new RedisSentinelFailoverCoordinator( + sentinelDeployments, + created, + sentinelConnector, + this::qualifyCandidate, + this::observeSentinelInstall, + probeTimeout, + drainTimeout, + sentinelDiscoveryRefreshPeriod, + longer( + clientSettings.shutdownTimeout().plus(SENTINEL_CLEANUP_COMPLETION_MARGIN), + drainTimeout), + Objects.requireNonNull(workerFactory, "workerFactory must be non-null")); + coordinatorReference.set(createdCoordinator); + } + } catch (RuntimeException exception) { + if (createdCoordinator != null) { + createdCoordinator.close(); + } + created.values().forEach(RedisRoleCommandRouter::close); + throw exception; + } + this.routers = Map.copyOf(created); + this.observations = Map.copyOf(createdObservations); + this.recoveries = Map.copyOf(createdRecoveries); + this.failoverCoordinator = createdCoordinator; + } + + Set boundRoles() { + return routers.keySet(); + } + + boolean isClosed() { + return closed.get(); + } + + RedisRoleCommandRouter router(RedisRole role) { + RedisRoleCommandRouter router = + routers.get(Objects.requireNonNull(role, "role must be non-null")); + if (router == null) { + throw new IllegalStateException("Redis role is not canonically bound: " + role); + } + return router; + } + + RedisRoleCommandRouter.SwapResult rotate(RedisRole role, RedisRoutableCommandRuntime candidate) { + Objects.requireNonNull(candidate, "candidate must be non-null"); + RedisSemanticProbePlan plan = probePlans.get(role); + if (plan == null) { + return router(role).swap(candidate, probeTimeout, drainTimeout); + } + RedisRoleCommandRouter qualificationRouter = + new RedisRoleCommandRouter( + role, + candidate, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + drainTimeout, + defaultWriteTtl); + Reason qualification = semanticProbe.probe(plan, qualificationRouter); + if (qualification != Reason.SEMANTIC_PROBE_SUCCEEDED) { + qualificationRouter.close(); + return RedisRoleCommandRouter.SwapResult.PROBE_FAILED; + } + RedisRoutableCommandRuntime qualified = + qualificationRouter.releaseQualifiedRuntimeForTransfer(); + RedisRoleCommandRouter.SwapResult result; + try { + result = router(role).swap(qualified, probeTimeout, drainTimeout); + } catch (RuntimeException failure) { + closeQuietly(qualified); + throw failure; + } + if (result != RedisRoleCommandRouter.SwapResult.PROBE_FAILED) { + observations.get(role).seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + } + return result; + } + + void probe(RedisRole role) { + router(role).probe(probeTimeout); + } + + @Override + public Snapshot snapshot() { + List roles = new ArrayList<>(bindings.size()); + for (RedisRole role : RedisRole.values()) { + RedisRoleBinding binding = bindings.get(role); + if (binding != null) { + roles.add(probeHealth(role, binding)); + } + } + return new Snapshot(clock.instant(), roles); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + if (failoverCoordinator != null) { + failoverCoordinator.close(); + } + recoveries.values().forEach(recovery -> recovery.markTerminal(terminalClosed())); + routers.values().forEach(RedisRoleCommandRouter::close); + } + } + + private static void rejectUnsupportedTopology( + RedisRole role, RedisDeploymentSettings deployment) { + if (role == RedisRole.SESSION && deployment instanceof RedisDeploymentSettings.Cluster) { + throw new UnsupportedOperationException( + "Redis SESSION role cannot use Cluster until session rotation preserves one hash slot"); + } + } + + private RoleHealth probeHealth(RedisRole role, RedisRoleBinding binding) { + RedisRoleCommandRouter router = router(role); + RedisSemanticProbeObservationCache observationCache = observations.get(role); + RedisSemanticProbeObservationCache.Observation observation; + RecoveryState recovery = recoveries.get(role); + if (closed.get()) { + observation = observationCache.seed(Reason.ROUTE_CLOSED); + } else if (recovery != null && !recovery.active()) { + observation = + recovery.deployment() instanceof RedisDeploymentSettings.Sentinel + ? observationCache.seed(Reason.COMMAND_UNAVAILABLE) + : observationCache.observe(() -> recover(role, recovery).reason()); + } else if (router.isClosed()) { + observation = observationCache.seed(Reason.ROUTE_CLOSED); + } else if (router.hadRecentCommandFailure()) { + observation = observationCache.seed(Reason.RECENT_COMMAND_FAILURE); + } else { + observation = + observationCache.observe(() -> semanticProbe.probe(probePlans.get(role), router)); + } + if (closed.get() && observation.reason() != Reason.ROUTE_CLOSED) { + observation = observationCache.seed(Reason.ROUTE_CLOSED); + } + Reason reason = observation.reason(); + State state = + switch (reason) { + case SEMANTIC_PROBE_SUCCEEDED -> State.AVAILABLE; + case COMMAND_SATURATED -> State.OVERLOADED; + default -> State.UNAVAILABLE; + }; + RoleHealth health = + new RoleHealth( + Role.valueOf(role.name()), + binding.deploymentId(), + binding.required(), + EvictionPolicy.valueOf( + binding.expectedEviction().trim().replace('-', '_').toUpperCase(Locale.ROOT)), + EvictionAttestation.CONFIGURED_EXPECTATION_ONLY, + capabilities.getOrDefault(role, Set.of()), + state, + reason, + observation.observedAt(), + observation.age().toMillis(), + observation.stale()); + capabilities + .getOrDefault(role, Set.of()) + .forEach( + capability -> + observationsPort.observe( + new RedisCapabilityObservationEvent.ReadinessObserved( + RedisCapabilityObservationEvent.Capability.valueOf(capability.name()), + RedisCapabilityObservationEvent.Role.valueOf(health.role().name()), + health.state(), + health.reason(), + health.required() + ? RedisCapabilityObservationEvent.Requirement.REQUIRED + : RedisCapabilityObservationEvent.Requirement.OPTIONAL))); + return health; + } + + private RedisSemanticReadinessProbe.Result recover(RedisRole role, RecoveryState recovery) { + if (closed.get()) { + return recovery.markTerminal(terminalClosed()); + } + RedisSemanticReadinessProbe.Result terminal = recovery.terminal(); + if (terminal != null) { + return terminal; + } + RedisRoutableCommandRuntime candidate; + try { + candidate = runtimeFactory.connect(recovery.deployment()); + } catch (RedisTemporaryConnectionException temporary) { + return retryableUnavailable(); + } catch (RuntimeException permanent) { + return recovery.markTerminal(terminalUnavailable()); + } + if (closed.get()) { + closeQuietly(candidate); + return recovery.markTerminal(terminalClosed()); + } + RedisRoleCommandRouter qualificationRouter = newRouter(role, candidate); + RedisSemanticReadinessProbe.Result qualification = + semanticProbe.probeResult(probePlans.get(role), qualificationRouter); + if (closed.get()) { + qualificationRouter.close(); + return recovery.markTerminal(terminalClosed()); + } + if (qualification.disposition() == RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT) { + qualificationRouter.close(); + return recovery.markTerminal(qualification); + } + if (qualification.disposition() + == RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT) { + qualificationRouter.close(); + return qualification; + } + RedisRoutableCommandRuntime qualified = + qualificationRouter.releaseQualifiedRuntimeForTransfer(); + if (closed.get()) { + closeQuietly(qualified); + return recovery.markTerminal(terminalClosed()); + } + RedisRoleCommandRouter.SwapResult swap; + try { + swap = router(role).swap(qualified, probeTimeout, drainTimeout); + } catch (RuntimeException failure) { + closeQuietly(qualified); + return closed.get() ? recovery.markTerminal(terminalClosed()) : retryableUnavailable(); + } + if (swap == RedisRoleCommandRouter.SwapResult.PROBE_FAILED) { + return retryableUnavailable(); + } + if (closed.get() || !recovery.markActive()) { + return recovery.markTerminal(terminalClosed()); + } + return qualification; + } + + private void installDormant( + RedisRole role, + RedisDeploymentSettings deployment, + Map created, + Map createdObservations, + Map createdRecoveries, + Duration minimumInterval, + Duration maximumStaleness, + LongSupplier ticker, + RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) { + created.put( + role, + newRouter( + role, + new RedisDormantCommandRuntime(deployment.deploymentId()), + topologyFailureListener)); + RedisSemanticProbeObservationCache observation = + new RedisSemanticProbeObservationCache(minimumInterval, maximumStaleness, clock, ticker); + observation.seed(Reason.COMMAND_UNAVAILABLE); + createdObservations.put(role, observation); + createdRecoveries.put(role, new RecoveryState(deployment)); + } + + private RedisRoleCommandRouter newRouter(RedisRole role, RedisRoutableCommandRuntime runtime) { + return newRouter(role, runtime, RedisRoleCommandRouter.TopologyFailureListener.ignore()); + } + + private RedisRoleCommandRouter newRouter( + RedisRole role, + RedisRoutableCommandRuntime runtime, + RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) { + return new RedisRoleCommandRouter( + role, + runtime, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + drainTimeout, + defaultWriteTtl, + ticker, + observationsPort, + RedisDrainWaiter.system(), + topologyFailureListener); + } + + private RedisSentinelFailoverCoordinator.CandidateQualification qualifyCandidate( + RedisRole role, RedisRoutableCommandRuntime candidate) { + Objects.requireNonNull(candidate, "candidate must be non-null"); + if (closed.get()) { + return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED; + } + RedisSemanticProbePlan plan = probePlans.get(role); + if (plan == null) { + return RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED; + } + RedisRoleCommandRouter qualificationRouter; + try { + qualificationRouter = newRouter(role, candidate); + } catch (RuntimeException failure) { + return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED; + } + RedisSemanticReadinessProbe.Result qualification; + try { + qualification = semanticProbe.probeResult(plan, qualificationRouter); + } catch (RuntimeException failure) { + detachQualificationRouter(qualificationRouter); + return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED; + } + if (!detachQualificationRouter(qualificationRouter)) { + return RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED; + } + return qualification.disposition() == RedisSemanticReadinessProbe.Disposition.SUCCEEDED + && !closed.get() + ? RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED + : RedisSentinelFailoverCoordinator.CandidateQualification.REJECTED; + } + + private void observeSentinelInstall(RedisRole role, RedisRoleCommandRouter.SwapResult result) { + if (result == RedisRoleCommandRouter.SwapResult.DRAINED + || result == RedisRoleCommandRouter.SwapResult.FORCED_AFTER_TIMEOUT) { + RedisSemanticProbeObservationCache observation = observations.get(role); + if (observation != null) { + observation.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + } + RecoveryState recovery = recoveries.get(role); + if (recovery != null) { + recovery.markActive(); + } + } + } + + private static boolean detachQualificationRouter(RedisRoleCommandRouter qualificationRouter) { + try { + qualificationRouter.releaseQualifiedRuntimeForTransfer(); + return true; + } catch (RuntimeException failure) { + return false; + } + } + + private static RedisRoutableCommandRuntime connectSentinel( + RedisSentinelRuntimeConnector connector, RedisDeploymentSettings.Sentinel deployment) { + RedisSentinelDiscoveredRoute route = connector.discover(deployment); + return connector.connect(deployment, route); + } + + private static Map sentinelDeployments( + Map deployments) { + EnumMap sentinels = new EnumMap<>(RedisRole.class); + deployments.forEach( + (role, deployment) -> { + if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) { + sentinels.put(role, sentinel); + } + }); + return Map.copyOf(sentinels); + } + + private static Duration longer(Duration first, Duration second) { + return first.compareTo(second) >= 0 ? first : second; + } + + private boolean isOptionalCache(RedisRole role) { + RedisRoleBinding binding = bindings.get(role); + return role == RedisRole.CACHE && binding != null && !binding.required(); + } + + private static RedisSemanticReadinessProbe.Result retryableUnavailable() { + return new RedisSemanticReadinessProbe.Result( + Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.RETRYABLE_TRANSPORT); + } + + private static RedisSemanticReadinessProbe.Result terminalUnavailable() { + return new RedisSemanticReadinessProbe.Result( + Reason.COMMAND_UNAVAILABLE, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT); + } + + private static RedisSemanticReadinessProbe.Result terminalClosed() { + return new RedisSemanticReadinessProbe.Result( + Reason.ROUTE_CLOSED, RedisSemanticReadinessProbe.Disposition.TERMINAL_CONTRACT); + } + + private static void closeQuietly(RedisRoutableCommandRuntime runtime) { + try { + runtime.close(); + } catch (RuntimeException ignored) { + // Recovery cleanup cannot expose provider detail through health. + } + } + + private static final class RecoveryState { + + private final RedisDeploymentSettings deployment; + private volatile RedisSemanticReadinessProbe.Result terminal; + private volatile boolean active; + + private RecoveryState(RedisDeploymentSettings deployment) { + this.deployment = deployment; + } + + private synchronized RedisDeploymentSettings deployment() { + return deployment; + } + + private synchronized RedisSemanticReadinessProbe.Result terminal() { + return terminal; + } + + private synchronized boolean active() { + return active; + } + + private synchronized RedisSemanticReadinessProbe.Result markTerminal( + RedisSemanticReadinessProbe.Result result) { + if (!active && terminal == null) { + terminal = result; + } + return terminal == null ? result : terminal; + } + + private synchronized boolean markActive() { + if (terminal != null) { + return false; + } + active = true; + return true; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationEvent.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationEvent.java new file mode 100644 index 0000000..edafb95 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationEvent.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import java.time.Duration; +import java.util.Objects; + +/** Closed, identity-free operational facts emitted only inside the Redis adapter leaf. */ +final class RedisCapabilityObservationEvent { + + static final long MAXIMUM_DURATION_NANOS = Duration.ofMinutes(5).toNanos(); + static final int MAXIMUM_IN_FLIGHT_COMMANDS = 4096; + static final long MAXIMUM_IN_FLIGHT_BYTES = 268_435_456L; + + private RedisCapabilityObservationEvent() {} + + sealed interface Event + permits OperationCompleted, AdmissionChanged, ReadinessObserved, LifecycleDrainCompleted {} + + record OperationCompleted( + Capability capability, + Role role, + Operation operation, + Outcome outcome, + Certainty certainty, + long durationNanos) + implements Event { + + public OperationCompleted { + Objects.requireNonNull(capability, "capability must be non-null"); + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(operation, "operation must be non-null"); + Objects.requireNonNull(outcome, "outcome must be non-null"); + Objects.requireNonNull(certainty, "certainty must be non-null"); + if (durationNanos < 0 || durationNanos > MAXIMUM_DURATION_NANOS) { + throw new IllegalArgumentException("durationNanos must be non-negative and bounded"); + } + } + } + + record AdmissionChanged( + Role role, + AdmissionState admission, + InFlightState state, + int inFlightCommands, + long inFlightBytes) + implements Event { + + public AdmissionChanged { + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(admission, "admission must be non-null"); + Objects.requireNonNull(state, "state must be non-null"); + if (inFlightCommands < 0 || inFlightCommands > MAXIMUM_IN_FLIGHT_COMMANDS) { + throw new IllegalArgumentException("inFlightCommands must be non-negative and bounded"); + } + if (inFlightBytes < 0 || inFlightBytes > MAXIMUM_IN_FLIGHT_BYTES) { + throw new IllegalArgumentException("inFlightBytes must be non-negative and bounded"); + } + } + } + + record ReadinessObserved( + Capability capability, + Role role, + RedisHealthSnapshotProvider.State state, + RedisHealthSnapshotProvider.Reason reason, + Requirement requirement) + implements Event { + + public ReadinessObserved { + Objects.requireNonNull(capability, "capability must be non-null"); + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(state, "state must be non-null"); + Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(requirement, "requirement must be non-null"); + } + } + + record LifecycleDrainCompleted(Role role, DrainOutcome drainOutcome) implements Event { + + public LifecycleDrainCompleted { + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(drainOutcome, "drainOutcome must be non-null"); + } + } + + enum Capability { + CACHE, + RATE_LIMIT, + IDEMPOTENCY, + EFFICIENCY_LEASE, + SESSION, + RUNTIME + } + + enum Role { + CACHE, + COORDINATION, + SESSION + } + + enum Operation { + LOOKUP, + RECORD, + INVALIDATE, + REFRESH_CLAIM, + REFRESH_RELEASE, + RATE_EVALUATE, + IDEMPOTENCY_CLAIM, + IDEMPOTENCY_START, + IDEMPOTENCY_RENEW, + IDEMPOTENCY_COMPLETE, + IDEMPOTENCY_FAIL, + IDEMPOTENCY_RELEASE, + IDEMPOTENCY_INSPECT, + LEASE_ACQUIRE, + LEASE_INSPECT, + LEASE_RENEW, + LEASE_RELEASE, + SESSION_CREATE, + SESSION_INSPECT, + SESSION_SAVE, + SESSION_TOUCH, + SESSION_REVOKE, + SESSION_ROTATE, + ROUTE_COMMAND + } + + enum Outcome { + SUCCESS, + HIT, + MISS, + DENIED, + CONTENDED, + CONFLICT, + INCOMPATIBLE, + UNAVAILABLE, + OVERLOADED, + CLOSED, + INDETERMINATE, + STALE, + SKIPPED, + TOMBSTONED, + ABSOLUTE_EXPIRED + } + + enum Certainty { + DEFINITE, + NOT_APPLIED, + INDETERMINATE + } + + enum AdmissionState { + ADMITTED, + REJECTED_SATURATED, + REJECTED_CLOSED, + NOT_APPLICABLE + } + + enum InFlightState { + IDLE, + ACTIVE, + SATURATED + } + + enum Requirement { + OPTIONAL, + REQUIRED + } + + enum DrainOutcome { + DRAINED, + FORCED_AFTER_TIMEOUT, + INTERRUPTED + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationPort.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationPort.java new file mode 100644 index 0000000..d519b59 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationPort.java @@ -0,0 +1,7 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +@FunctionalInterface +interface RedisCapabilityObservationPort { + + void observe(RedisCapabilityObservationEvent.Event event); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObserver.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObserver.java new file mode 100644 index 0000000..e7c5c9d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObserver.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; +import java.util.function.Function; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** Measures one logical semantic operation without accepting request identity or wire material. */ +final class RedisCapabilityObserver { + + private static final long UNAVAILABLE_TICK = Long.MIN_VALUE; + + private final RedisCapabilityObservationPort observations; + private final LongSupplier ticker; + + RedisCapabilityObserver(RedisCapabilityObservationPort observations, LongSupplier ticker) { + this.observations = + new SafeRedisCapabilityObservationPort( + Objects.requireNonNull(observations, "observations must be non-null")); + this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null"); + } + + static RedisCapabilityObserver disabled() { + return new RedisCapabilityObserver( + NoOpRedisCapabilityObservationPort.instance(), System::nanoTime); + } + + T observe( + RedisCapabilityObservationEvent.Capability capability, + RedisCapabilityObservationEvent.Role role, + RedisCapabilityObservationEvent.Operation operation, + Supplier action, + Function classifier) { + return observe( + capability, + role, + operation, + action, + classifier, + ignored -> + new Classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED)); + } + + T observe( + RedisCapabilityObservationEvent.Capability capability, + RedisCapabilityObservationEvent.Role role, + RedisCapabilityObservationEvent.Operation operation, + Supplier action, + Function classifier, + Function failureClassifier) { + Objects.requireNonNull(action, "action must be non-null"); + Objects.requireNonNull(classifier, "classifier must be non-null"); + Objects.requireNonNull(failureClassifier, "failureClassifier must be non-null"); + long started = safeTick(); + T result; + try { + result = action.get(); + } catch (RuntimeException failure) { + Classification failureClassification; + try { + failureClassification = + Objects.requireNonNull( + failureClassifier.apply(failure), "failure classification must be non-null"); + } catch (RuntimeException diagnosticFailure) { + throw failure; + } + completedSafely(capability, role, operation, failureClassification, started); + throw failure; + } + Classification classification; + try { + classification = + Objects.requireNonNull(classifier.apply(result), "classification must be non-null"); + } catch (RuntimeException diagnosticFailure) { + return result; + } + completedSafely(capability, role, operation, classification, started); + return result; + } + + private void completedSafely( + RedisCapabilityObservationEvent.Capability capability, + RedisCapabilityObservationEvent.Role role, + RedisCapabilityObservationEvent.Operation operation, + Classification classification, + long started) { + try { + long finished = safeTick(); + long elapsed = + started == UNAVAILABLE_TICK || finished == UNAVAILABLE_TICK ? 0L : finished - started; + long bounded = + Math.min(RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS, Math.max(0L, elapsed)); + observations.observe( + new RedisCapabilityObservationEvent.OperationCompleted( + capability, + role, + operation, + classification.outcome(), + classification.certainty(), + bounded)); + } catch (RuntimeException ignored) { + // Diagnostic timing/event construction cannot change the authoritative command result. + } + } + + private long safeTick() { + try { + return ticker.getAsLong(); + } catch (RuntimeException ignored) { + return UNAVAILABLE_TICK; + } + } + + record Classification( + RedisCapabilityObservationEvent.Outcome outcome, + RedisCapabilityObservationEvent.Certainty certainty) { + + Classification { + Objects.requireNonNull(outcome, "outcome must be non-null"); + Objects.requireNonNull(certainty, "certainty must be non-null"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramInvocation.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramInvocation.java new file mode 100644 index 0000000..bf50ac2 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramInvocation.java @@ -0,0 +1,321 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Validated catalog-owned invocation. It is the only program identity carried through command + * ports; raw SHA, Lua source and raw key collections never cross those ports. + */ +final class RedisCatalogProgramInvocation { + + enum ReplyShape { + VALUE, + READ_ONLY_VALUE, + MULTI, + READ_ONLY_MULTI + } + + private final RedisProgramDescriptor descriptor; + private final byte[] exactScript; + private final String externalId; + private final List keys; + private final List arguments; + private final ReplyShape replyShape; + private final int encodedBytes; + private final Supplier remainingBudget; + + private RedisCatalogProgramInvocation( + RedisProgramCatalog owner, + RedisProgramDescriptor descriptor, + List keys, + List arguments, + ReplyShape replyShape, + Supplier remainingBudget) { + Objects.requireNonNull(owner, "owner must be non-null"); + this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null"); + this.exactScript = descriptor.scriptBytes(); + this.externalId = descriptor.id().externalId(); + if (owner.descriptor(descriptor.id()) != descriptor) { + throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog"); + } + Objects.requireNonNull(keys, "keys must be non-null"); + Objects.requireNonNull(arguments, "arguments must be non-null"); + if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { + throw new IllegalArgumentException("Redis program signature does not match descriptor"); + } + List safeKeys = new ArrayList<>(keys.size()); + long bytes = 0; + for (byte[] key : keys) { + if (key == null || key.length < 1 || key.length > descriptor.maximumKeyBytes()) { + throw new IllegalArgumentException("Redis program key is out of bounds"); + } + safeKeys.add(new Key(key)); + bytes += key.length; + } + List safeArguments = new ArrayList<>(arguments.size()); + for (byte[] argument : arguments) { + if (argument == null + || argument.length < 1 + || argument.length > descriptor.maximumArgumentBytes()) { + throw new IllegalArgumentException("Redis program argument is out of bounds"); + } + Argument safeArgument = new Argument(argument); + safeArguments.add(safeArgument); + bytes += safeArgument.encodedLength(); + } + if (bytes > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Redis program invocation is too large"); + } + this.keys = List.copyOf(safeKeys); + this.arguments = List.copyOf(safeArguments); + this.replyShape = Objects.requireNonNull(replyShape, "replyShape must be non-null"); + this.encodedBytes = (int) bytes; + this.remainingBudget = remainingBudget; + } + + private RedisCatalogProgramInvocation(RedisSemanticReadinessProbe.AclProbeMaterial material) { + this.descriptor = null; + this.externalId = "semantic-capability-acl-v1"; + this.exactScript = RedisSemanticAclProbeCatalog.scriptBytes(); + List keys = material.copyKeys(); + Objects.requireNonNull(keys, "keys must be non-null"); + List safeKeys = new ArrayList<>(keys.size()); + long bytes = 0; + for (byte[] key : keys) { + if (key == null || key.length < 1 || key.length > 512) { + throw new IllegalArgumentException("Redis program key is out of bounds"); + } + safeKeys.add(new Key(key)); + bytes += key.length; + } + byte[] encodedCapability = + Objects.requireNonNull(material.capability(), "capability must be non-null") + .name() + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + List safeArguments = List.of(new Argument(encodedCapability)); + bytes += encodedCapability.length; + if (bytes > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Redis program invocation is too large"); + } + this.keys = List.copyOf(safeKeys); + this.arguments = List.copyOf(safeArguments); + this.replyShape = ReplyShape.READ_ONLY_VALUE; + this.encodedBytes = (int) bytes; + this.remainingBudget = null; + } + + static RedisCatalogProgramInvocation capabilityOwned( + RedisProgramCatalog owner, RedisCatalogProgramMaterial material, ReplyShape replyShape) { + RedisProgramDescriptor descriptor = owner.descriptor(material.programId()); + return new RedisCatalogProgramInvocation( + owner, descriptor, material.copyKeys(), material.copyArguments(), replyShape, null); + } + + static RedisCatalogProgramInvocation primitiveOwned( + RedisProgramCatalog owner, + RedisProgramDescriptor descriptor, + RedisPrimitiveInvocation primitive, + ReplyShape replyShape) { + if (primitive.descriptor().programId() != descriptor.id()) { + throw new IllegalArgumentException("primitive program identity is inconsistent"); + } + return new RedisCatalogProgramInvocation( + owner, + descriptor, + primitiveKeys(primitive), + primitiveArguments(descriptor, primitive), + replyShape, + primitive::remainingDeadline); + } + + static RedisCatalogProgramInvocation boundedGetOwned( + RedisProgramCatalog owner, + RedisProgramDescriptor descriptor, + RedisPhysicalKey key, + int maximumValueBytes) { + if (descriptor.id() != RedisProgramId.BOUNDED_GET_V1) { + throw new IllegalArgumentException("bounded GET descriptor is required"); + } + return new RedisCatalogProgramInvocation( + owner, + descriptor, + List.of(RedisPhysicalKey.WireCodec.copy(key)), + List.of( + Integer.toString(maximumValueBytes) + .getBytes(java.nio.charset.StandardCharsets.US_ASCII)), + ReplyShape.READ_ONLY_VALUE, + null); + } + + static RedisCatalogProgramInvocation semanticAclProbe( + RedisSemanticReadinessProbe.AclProbeMaterial material) { + return new RedisCatalogProgramInvocation( + Objects.requireNonNull(material, "semantic ACL material must be non-null")); + } + + private static List primitiveKeys(RedisPrimitiveInvocation primitive) { + return primitive.keys().stream() + .map(RedisPrimitiveKey::physicalKey) + .map(RedisPhysicalKey.WireCodec::copy) + .toList(); + } + + private static List primitiveArguments( + RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive) { + if (descriptor.id() == RedisProgramId.BOUNDED_GET_V1) { + return List.of( + Integer.toString(primitive.descriptor().maximumValueBytes()) + .getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + if (!(primitive.arguments() instanceof RedisPrimitiveInvocation.ProgramArguments arguments)) { + throw new IllegalArgumentException("primitive program arguments are not closed"); + } + return arguments.programValues().stream().map(RedisPrimitiveValue::copyEncoded).toList(); + } + + RedisProgramDescriptor descriptor() { + if (descriptor == null) { + throw new IllegalStateException("Redis exact program has no manifest descriptor"); + } + return descriptor; + } + + RedisProgramId programIdOrNull() { + return descriptor == null ? null : descriptor.id(); + } + + String externalId() { + return externalId; + } + + private byte[] copyExactScript() { + return exactScript.clone(); + } + + ReplyShape replyShape() { + return replyShape; + } + + int keyCount() { + return keys.size(); + } + + int argumentCount() { + return arguments.size(); + } + + private byte[] copyArgument(int index) { + return arguments.get(index).copyEncoded(); + } + + int encodedBytes() { + return encodedBytes; + } + + Duration boundedTimeout(Duration defaultTimeout) { + Objects.requireNonNull(defaultTimeout, "defaultTimeout must be non-null"); + if (remainingBudget == null) { + return defaultTimeout; + } + Duration remaining = remainingBudget.get(); + return remaining.compareTo(defaultTimeout) < 0 ? remaining : defaultTimeout; + } + + private byte[][] copyKeysArray() { + byte[][] result = new byte[keys.size()][]; + for (int index = 0; index < keys.size(); index++) { + result[index] = keys.get(index).copyEncoded(); + } + return result; + } + + private List copyKeys() { + List result = new ArrayList<>(keys.size()); + for (Key key : keys) { + result.add(key.copyEncoded()); + } + return List.copyOf(result); + } + + private List copyArguments() { + List result = new ArrayList<>(arguments.size()); + for (Argument argument : arguments) { + result.add(argument.copyEncoded()); + } + return List.copyOf(result); + } + + String sha1() { + return RedisScriptRecovery.sha1(exactScript); + } + + private byte[][] copyArgumentsArray() { + byte[][] result = new byte[arguments.size()][]; + for (int index = 0; index < arguments.size(); index++) { + result[index] = arguments.get(index).copyEncoded(); + } + return result; + } + + private static final class Argument { + private final byte[] encoded; + + private Argument(byte[] encoded) { + this.encoded = encoded.clone(); + } + + private int encodedLength() { + return encoded.length; + } + + private byte[] copyEncoded() { + return encoded.clone(); + } + } + + private static final class Key { + private final byte[] encoded; + + private Key(byte[] encoded) { + this.encoded = encoded.clone(); + } + + private byte[] copyEncoded() { + return encoded.clone(); + } + } + + /** Sole terminal wire unwrap; every byte is derived from an already validated invocation. */ + static final class WireCodec { + + private WireCodec() {} + + static byte[] exactScript(RedisCatalogProgramInvocation invocation) { + return invocation.copyExactScript(); + } + + static byte[] argument(RedisCatalogProgramInvocation invocation, int index) { + return invocation.copyArgument(index); + } + + static byte[][] keysArray(RedisCatalogProgramInvocation invocation) { + return invocation.copyKeysArray(); + } + + static byte[][] argumentsArray(RedisCatalogProgramInvocation invocation) { + return invocation.copyArgumentsArray(); + } + + static List keys(RedisCatalogProgramInvocation invocation) { + return invocation.copyKeys(); + } + + static List arguments(RedisCatalogProgramInvocation invocation) { + return invocation.copyArguments(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramMaterial.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramMaterial.java new file mode 100644 index 0000000..fe2af32 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramMaterial.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; + +/** + * Closed capability-owned material consumed only while constructing a catalog invocation. + * + *

Every permitted implementation has a private constructor in its semantic owner. No command + * executor receives this raw material and no arbitrary package peer can implement the contract. + */ +sealed interface RedisCatalogProgramMaterial + permits RedisAtomicPrimitives.ProgramMaterial, + RedisEdgeRateLimitProvider.ProgramInvocation, + RedisEfficiencyLeaseProvider.ProgramInvocation, + RedisEfficiencyLeaseHandle.ProgramInvocation, + RedisIdempotencyStoreProvider.ProgramInvocation, + RedisLuaVersionedSessionStore.ProgramInvocation, + RedisSemanticReadinessProbe.ProgramInvocation { + + RedisProgramId programId(); + + RedisCatalogProgramInvocation.ReplyShape replyShape(); + + List copyKeys(); + + List copyArguments(); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramReply.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramReply.java new file mode 100644 index 0000000..e3ff301 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCatalogProgramReply.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.ArrayList; +import java.util.List; + +/** Bounded defensive reply from one catalog invocation. */ +final class RedisCatalogProgramReply { + + private final byte[] value; + private final List fields; + + private RedisCatalogProgramReply(byte[] value, List fields) { + this.value = value == null ? null : value.clone(); + this.fields = defensive(fields); + } + + static RedisCatalogProgramReply value(byte[] value) { + return new RedisCatalogProgramReply(value, List.of()); + } + + static RedisCatalogProgramReply multi(List fields) { + return new RedisCatalogProgramReply(null, fields); + } + + byte[] copyValue() { + return value == null ? null : value.clone(); + } + + List copyFields() { + return defensive(fields); + } + + private static List defensive(List fields) { + if (fields == null || fields.isEmpty()) { + return List.of(); + } + List safe = new ArrayList<>(fields.size()); + for (byte[] field : fields) { + safe.add(field == null ? null : field.clone()); + } + return List.copyOf(safe); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java index c6e6562..78060e4 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCommandFailureException.java @@ -1,5 +1,7 @@ package dev.caskeleton.adapter.outbound.cache.redis; +import java.util.Objects; + /** Adapter-internal transport failure with explicit overload and mutation certainty. */ final class RedisCommandFailureException extends RuntimeException { @@ -7,11 +9,18 @@ final class RedisCommandFailureException extends RuntimeException { private final Kind kind; private final Certainty certainty; + private final RecoveryHint recoveryHint; RedisCommandFailureException(Kind kind, Certainty certainty, String message, Throwable cause) { + this(kind, certainty, RecoveryHint.NONE, message, cause); + } + + RedisCommandFailureException( + Kind kind, Certainty certainty, RecoveryHint recoveryHint, String message, Throwable cause) { super(message, cause); - this.kind = kind; - this.certainty = certainty; + this.kind = Objects.requireNonNull(kind, "kind must be non-null"); + this.certainty = Objects.requireNonNull(certainty, "certainty must be non-null"); + this.recoveryHint = Objects.requireNonNull(recoveryHint, "recoveryHint must be non-null"); } Kind kind() { @@ -22,13 +31,23 @@ final class RedisCommandFailureException extends RuntimeException { return certainty; } + RecoveryHint recoveryHint() { + return recoveryHint; + } + enum Kind { UNAVAILABLE, - OVERLOADED + OVERLOADED, + ACL_DENIED } enum Certainty { NOT_APPLIED, INDETERMINATE } + + enum RecoveryHint { + NONE, + REDISCOVER_SENTINEL + } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfile.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfile.java new file mode 100644 index 0000000..7ad9884 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfile.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Objects; + +/** Adapter-internal immutable connection/admission profile for one dedicated Redis role. */ +record RedisConnectionProfile( + String host, + int port, + String password, + Duration commandTimeout, + Duration legacyTtl, + int maximumReadableValueBytes, + int maximumCommandBytes, + int maximumQueuedCommands, + int maximumInFlightBytes) { + + private static final int LEGACY_COMMAND_OVERHEAD_BYTES = 4_096; + + RedisConnectionProfile { + Objects.requireNonNull(host, "host must be non-null"); + Objects.requireNonNull(password, "password must be non-null"); + Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null"); + Objects.requireNonNull(legacyTtl, "legacyTtl must be non-null"); + if (maximumReadableValueBytes < 1 || maximumCommandBytes < 1) { + throw new IllegalArgumentException("Redis byte bounds must be positive"); + } + } + + static RedisConnectionProfile cache(RedisRuntimeSettings settings) { + Objects.requireNonNull(settings, "settings must be non-null"); + return new RedisConnectionProfile( + settings.host(), + settings.port(), + settings.password(), + settings.commandTimeout(), + settings.positiveTtl(), + settings.maximumReadableValueBytes(), + settings.maximumCommandBytes(), + settings.maximumQueuedCommands(), + settings.maximumInFlightBytes()); + } + + static RedisConnectionProfile rateLimit(RedisLegacyStandaloneSettings settings) { + Objects.requireNonNull(settings, "settings must be non-null"); + return new RedisConnectionProfile( + settings.host(), + settings.port(), + settings.password(), + settings.commandTimeout(), + Duration.ofSeconds(1), + settings.maximumCommandBytes() - LEGACY_COMMAND_OVERHEAD_BYTES, + settings.maximumCommandBytes(), + settings.maximumQueuedCommands(), + settings.maximumInFlightBytes()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterPrimitives.java new file mode 100644 index 0000000..d656a08 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterPrimitives.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Signed exact counter helpers; increment is atomic with initial TTL. */ +final class RedisCounterPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + + RedisCounterPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.COUNTER_READ).key(slot, identity); + } + + RedisPrimitiveReply read(RedisPrimitiveKey key) { + return executor.execute( + RedisPrimitiveId.COUNTER_READ, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE); + } + + RedisCounterResult increment( + RedisPrimitiveKey key, long delta, long minimum, long maximum, Duration initialTimeToLive) { + try { + return RedisCounterResult.from( + executor.execute( + RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL, + List.of(key), + new RedisPrimitiveInvocation.CounterArguments( + delta, minimum, maximum, initialTimeToLive))); + } catch (RedisCommandFailureException failure) { + return RedisCounterResult.failed(failure); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterResult.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterResult.java new file mode 100644 index 0000000..75bb0f1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCounterResult.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.OptionalLong; + +/** + * Exact signed counter outcome; the resulting value is never reinterpreted as an affected count. + */ +record RedisCounterResult( + Status status, Certainty certainty, OptionalLong value, String diagnosticCode) { + + enum Status { + UPDATED, + LIMIT_EXCEEDED, + OVERFLOW, + MISSING_TTL, + MALFORMED_VALUE, + WRONG_TYPE, + INVALID, + UNKNOWN + } + + enum Certainty { + APPLIED, + NOT_APPLIED, + INDETERMINATE + } + + RedisCounterResult { + if (status == null || certainty == null || value == null) { + throw new IllegalArgumentException("counter result is invalid"); + } + diagnosticCode = diagnosticCode == null ? "" : diagnosticCode; + } + + static RedisCounterResult from(RedisPrimitiveReply reply) { + Status status = + switch (reply.status()) { + case UPDATED -> Status.UPDATED; + case LIMIT_EXCEEDED -> Status.LIMIT_EXCEEDED; + case OVERFLOW -> Status.OVERFLOW; + case MISSING_TTL -> Status.MISSING_TTL; + case MALFORMED_VALUE -> Status.MALFORMED_VALUE; + case WRONG_TYPE -> Status.WRONG_TYPE; + case INVALID, TTL_APPLY_FAILED -> Status.INVALID; + default -> Status.UNKNOWN; + }; + return new RedisCounterResult( + status, + status == Status.UPDATED ? Certainty.APPLIED : Certainty.NOT_APPLIED, + reply.signedNumber(), + reply.diagnosticCode()); + } + + static RedisCounterResult failed(RedisCommandFailureException failure) { + return new RedisCounterResult( + Status.UNKNOWN, + failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? Certainty.INDETERMINATE + : Certainty.NOT_APPLIED, + OptionalLong.empty(), + ""); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntime.java new file mode 100644 index 0000000..3b6c922 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntime.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Lifecycle-safe Redis deployment runtime with no public native command surface. */ +final class RedisDeploymentRuntime implements RedisRotatableRuntime { + + public enum Topology { + STANDALONE, + SENTINEL, + CLUSTER + } + + public record Timeouts( + Duration connect, Duration acquire, Duration command, Duration overall, Duration shutdown) { + + public Timeouts { + Objects.requireNonNull(connect, "connect must be non-null"); + Objects.requireNonNull(acquire, "acquire must be non-null"); + Objects.requireNonNull(command, "command must be non-null"); + Objects.requireNonNull(overall, "overall must be non-null"); + Objects.requireNonNull(shutdown, "shutdown must be non-null"); + } + } + + private final String deploymentId; + private final Topology topology; + private final Timeouts timeouts; + private final RedisNativeClientHandle nativeClient; + private final RedisLettuceUris credentialOwner; + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisDeploymentRuntime( + String deploymentId, + Topology topology, + RedisClientRuntimeSettings settings, + RedisNativeClientHandle nativeClient, + RedisLettuceUris credentialOwner) { + this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null"); + this.topology = Objects.requireNonNull(topology, "topology must be non-null"); + Objects.requireNonNull(settings, "settings must be non-null"); + this.timeouts = + new Timeouts( + settings.connectTimeout(), + settings.acquireTimeout(), + settings.commandTimeout(), + settings.overallTimeout(), + settings.shutdownTimeout()); + this.nativeClient = Objects.requireNonNull(nativeClient, "nativeClient must be non-null"); + this.credentialOwner = + Objects.requireNonNull(credentialOwner, "credentialOwner must be non-null"); + } + + @Override + public String deploymentId() { + return deploymentId; + } + + public Topology topology() { + return topology; + } + + public Timeouts timeouts() { + return timeouts; + } + + public boolean isClosed() { + return closed.get(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + try { + nativeClient.close(timeouts.shutdown()); + } finally { + credentialOwner.close(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactory.java new file mode 100644 index 0000000..a9dc815 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactory.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory; +import io.lettuce.core.SslOptions; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Creates one topology-native runtime only for an explicitly bound Redis role. */ +final class RedisDeploymentRuntimeFactory { + + private final RedisLettuceUriFactory uriFactory; + private final RedisSslOptionsFactory sslOptionsFactory; + private final RedisLettuceClientOptionsFactory optionsFactory; + private final RedisNativeClientFactory nativeClientFactory; + + RedisDeploymentRuntimeFactory( + RedisLettuceUriFactory uriFactory, RedisSslOptionsFactory sslOptionsFactory) { + this( + uriFactory, + sslOptionsFactory, + new RedisLettuceClientOptionsFactory(), + new LettuceRedisNativeClientFactory()); + } + + RedisDeploymentRuntimeFactory( + RedisLettuceUriFactory uriFactory, + RedisSslOptionsFactory sslOptionsFactory, + RedisLettuceClientOptionsFactory optionsFactory, + RedisNativeClientFactory nativeClientFactory) { + this.uriFactory = Objects.requireNonNull(uriFactory, "uriFactory must be non-null"); + this.sslOptionsFactory = + Objects.requireNonNull(sslOptionsFactory, "sslOptionsFactory must be non-null"); + this.optionsFactory = Objects.requireNonNull(optionsFactory, "optionsFactory must be non-null"); + this.nativeClientFactory = + Objects.requireNonNull(nativeClientFactory, "nativeClientFactory must be non-null"); + } + + Optional createIfBound( + RedisRole role, + Map activeDeployments, + RedisClientRuntimeSettings clientSettings) { + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(activeDeployments, "activeDeployments must be non-null"); + RedisDeploymentSettings deployment = activeDeployments.get(role); + if (deployment == null) { + return Optional.empty(); + } + return Optional.of(create(deployment, clientSettings)); + } + + RedisDeploymentRuntime create( + RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(clientSettings, "clientSettings must be non-null"); + if (deployment instanceof RedisDeploymentSettings.Sentinel) { + throw new UnsupportedOperationException( + "Redis Sentinel separate discovery and data trust is unsupported by one Lettuce SSL" + + " context"); + } + SslOptions sslOptions = + sslOptionsFactory.create(deployment.dataTls(), clientSettings.tlsHandshakeTimeout()); + RedisLettuceUris uris = uriFactory.create(deployment, clientSettings); + try { + return switch (uris) { + case RedisLettuceUris.Standalone standalone -> + runtime( + deployment, + RedisDeploymentRuntime.Topology.STANDALONE, + clientSettings, + nativeClientFactory.openStandalone( + standalone.dataUri(), + optionsFactory.clientOptions(clientSettings, sslOptions), + clientSettings), + uris); + case RedisLettuceUris.Cluster cluster -> + runtime( + deployment, + RedisDeploymentRuntime.Topology.CLUSTER, + clientSettings, + nativeClientFactory.openCluster( + cluster.seedUris(), + optionsFactory.clusterClientOptions(clientSettings, sslOptions), + clientSettings), + uris); + case RedisLettuceUris.SentinelDiscovery ignored -> + throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed"); + case RedisLettuceUris.SentinelData ignored -> + throw new IllegalStateException("Redis Sentinel fail-closed guard was bypassed"); + }; + } catch (RuntimeException exception) { + uris.close(); + throw exception; + } + } + + private static RedisDeploymentRuntime runtime( + RedisDeploymentSettings deployment, + RedisDeploymentRuntime.Topology topology, + RedisClientRuntimeSettings settings, + RedisNativeClientHandle client, + RedisLettuceUris credentialOwner) { + try { + return new RedisDeploymentRuntime( + deployment.deploymentId(), topology, settings, client, credentialOwner); + } catch (RuntimeException exception) { + try { + client.close(settings.shutdownTimeout()); + } finally { + credentialOwner.close(); + } + throw exception; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDormantCommandRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDormantCommandRuntime.java new file mode 100644 index 0000000..8caa7e0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDormantCommandRuntime.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Objects; + +/** Non-owning unavailable route used while an optional CACHE deployment awaits recovery. */ +final class RedisDormantCommandRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + + RedisDormantCommandRuntime(String deploymentId) { + this.deploymentId = Objects.requireNonNull(deploymentId, "deploymentId must be non-null"); + } + + @Override + public void probe(Duration timeout) { + throw unavailable(); + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + throw unavailable(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + throw unavailable(); + } + + @Override + public long delete(RedisPhysicalKey key) { + throw unavailable(); + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + throw unavailable(); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) { + throw unavailable(); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + throw unavailable(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + throw unavailable(); + } + + @Override + public Subscription subscribe(byte[] channel, Listener listener) { + Objects.requireNonNull(listener, "listener must be non-null"); + return () -> {}; + } + + @Override + public void close() {} + + private static RedisCommandFailureException unavailable() { + return new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis optional role is temporarily unavailable", + null); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDrainWaiter.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDrainWaiter.java new file mode 100644 index 0000000..9fcb2fa --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDrainWaiter.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.IntSupplier; + +@FunctionalInterface +interface RedisDrainWaiter { + + Result await(IntSupplier inFlight, Object monitor, Duration timeout); + + static RedisDrainWaiter system() { + return (inFlight, monitor, timeout) -> { + Objects.requireNonNull(inFlight, "inFlight must be non-null"); + Objects.requireNonNull(monitor, "monitor must be non-null"); + Objects.requireNonNull(timeout, "timeout must be non-null"); + long deadline = saturatedAdd(System.nanoTime(), timeout.toNanos()); + synchronized (monitor) { + while (inFlight.getAsInt() > 0) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return Result.TIMED_OUT; + } + try { + TimeUnit.NANOSECONDS.timedWait(monitor, remaining); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return Result.INTERRUPTED; + } + } + return Result.DRAINED; + } + }; + } + + private static long saturatedAdd(long left, long right) { + try { + return Math.addExact(left, right); + } catch (ArithmeticException ignored) { + return Long.MAX_VALUE; + } + } + + enum Result { + DRAINED, + TIMED_OUT, + INTERRUPTED + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProvider.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProvider.java new file mode 100644 index 0000000..a08a371 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProvider.java @@ -0,0 +1,553 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.LongSupplier; + +/** Provider-neutral edge-rate port backed by one exact Redis Lua program per policy evaluation. */ +final class RedisEdgeRateLimitProvider implements EdgeRateLimitPort, AutoCloseable { + + private static final int PROGRAM_SCHEMA_VERSION = 2; + private static final long SCALE = 1_000_000L; + private static final long MAXIMUM_LIMIT = 1_000_000_000L; + private static final Duration MAXIMUM_WINDOW = Duration.ofDays(1); + private static final Duration MAXIMUM_GRACE = Duration.ofDays(1); + private static final Duration MAXIMUM_CLOCK_REGRESSION = Duration.ofHours(1); + private static final int MAXIMUM_KEY_BYTES = 512; + + private final Map policies; + private final RedisProgramCatalog catalog; + private final RedisRateProgramExecutor executor; + private final String application; + private final String environment; + private final int hashKeyVersion; + private final int keyVersion; + private final byte[] hmacSecret; + private final Clock clock; + private final Duration failureRetryAfter; + private final Duration minimumCallerBudget; + private final RedisCapabilityObserver observer; + private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock(); + private boolean closed; + + RedisEdgeRateLimitProvider( + Map policies, + RedisProgramCatalog catalog, + RedisRateProgramExecutor executor, + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret, + Clock clock, + Duration failureRetryAfter) { + this( + policies, + catalog, + executor, + application, + environment, + hashKeyVersion, + keyVersion, + hmacSecret, + clock, + failureRetryAfter, + Duration.ZERO, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisEdgeRateLimitProvider( + Map policies, + RedisProgramCatalog catalog, + RedisRateProgramExecutor executor, + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret, + Clock clock, + Duration failureRetryAfter, + Duration minimumCallerBudget) { + this( + policies, + catalog, + executor, + application, + environment, + hashKeyVersion, + keyVersion, + hmacSecret, + clock, + failureRetryAfter, + minimumCallerBudget, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisEdgeRateLimitProvider( + Map policies, + RedisProgramCatalog catalog, + RedisRateProgramExecutor executor, + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret, + Clock clock, + Duration failureRetryAfter, + Duration minimumCallerBudget, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { + this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies must be non-null")); + if (this.policies.isEmpty()) { + throw new IllegalArgumentException("Redis rate-limit provider requires at least one policy"); + } + this.policies.forEach( + (id, policy) -> { + Objects.requireNonNull(policy, "rate-limit policy must be non-null"); + if (!id.equals(policy.policyId())) { + throw new IllegalArgumentException("rate-limit policy map key must match policyId"); + } + validateProviderBounds(policy); + }); + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = Objects.requireNonNull(executor, "executor must be non-null"); + this.application = Objects.requireNonNull(application, "application must be non-null"); + this.environment = Objects.requireNonNull(environment, "environment must be non-null"); + this.hashKeyVersion = hashKeyVersion; + this.keyVersion = keyVersion; + this.hmacSecret = + Arrays.copyOf( + Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length); + if (this.hmacSecret.length < 32) { + throw new IllegalArgumentException( + "rate-limit key HMAC secret must contain at least 32 bytes"); + } + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.failureRetryAfter = + Objects.requireNonNull(failureRetryAfter, "failureRetryAfter must be non-null"); + if (failureRetryAfter.isZero() + || failureRetryAfter.isNegative() + || failureRetryAfter.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException("failureRetryAfter must be positive and bounded"); + } + this.minimumCallerBudget = + Objects.requireNonNull(minimumCallerBudget, "minimumCallerBudget must be non-null"); + if (minimumCallerBudget.isNegative() + || minimumCallerBudget.compareTo(Duration.ofSeconds(30)) > 0) { + throw new IllegalArgumentException("minimumCallerBudget must be non-negative and bounded"); + } + this.observer = new RedisCapabilityObserver(observations, ticker); + RateLimitPolicy first = this.policies.values().iterator().next(); + namespace(first, "state"); + } + + @Override + public RateLimitOutcome evaluate(RateLimitRequest request) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.RATE_LIMIT, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.RATE_EVALUATE, + () -> evaluateWithLifecycle(request), + RedisEdgeRateLimitProvider::classify); + } + + private RateLimitOutcome evaluateWithLifecycle(RateLimitRequest request) { + lifecycle.readLock().lock(); + try { + if (closed) { + throw new IllegalStateException("Redis rate-limit provider is closed"); + } + return evaluateOpen(request); + } finally { + lifecycle.readLock().unlock(); + } + } + + private RateLimitOutcome evaluateOpen(RateLimitRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + RateLimitPolicy policy = policies.get(request.policyId()); + if (policy == null) { + return incompatible( + request.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE); + } + if (request.cost() > policy.maximumCost()) { + throw new IllegalArgumentException("rate-limit request cost exceeds policy maximumCost"); + } + Instant now = clock.instant(); + if (!request.callerDeadline().isAfter(now) + || Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) < 0) { + return unavailable( + policy.policyId(), RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED); + } + + ProgramInvocation invocation = invocation(policy, request); + RedisRateProgramReply reply; + try { + reply = execute(invocation); + } catch (RedisProgramCompatibilityException exception) { + return incompatible( + policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE); + } catch (RedisCommandFailureException exception) { + if (!canReplayIndeterminate(policy, request, exception)) { + return mapCommandFailure(policy.policyId(), exception); + } + try { + reply = execute(invocation); + } catch (RedisProgramCompatibilityException retryException) { + return incompatible( + policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE); + } catch (RedisCommandFailureException retryException) { + return mapCommandFailure(policy.policyId(), retryException); + } + } + return mapReply(policy, reply); + } + + @Override + public void close() { + lifecycle.writeLock().lock(); + try { + if (!closed) { + Arrays.fill(hmacSecret, (byte) 0); + closed = true; + } + } finally { + lifecycle.writeLock().unlock(); + } + } + + boolean destroyed() { + lifecycle.readLock().lock(); + try { + if (!closed) { + return false; + } + for (byte value : hmacSecret) { + if (value != 0) { + return false; + } + } + return true; + } finally { + lifecycle.readLock().unlock(); + } + } + + private RedisRateProgramReply execute(ProgramInvocation invocation) { + return executor.execute(catalog.capabilityInvocation(invocation)); + } + + private boolean canReplayIndeterminate( + RateLimitPolicy policy, RateLimitRequest request, RedisCommandFailureException exception) { + if (exception.certainty() != RedisCommandFailureException.Certainty.INDETERMINATE + || !policy.evaluationDedupPolicy().enabled() + || request.evaluationId().isEmpty() + || minimumCallerBudget.isZero()) { + return false; + } + Instant now = clock.instant(); + return request.callerDeadline().isAfter(now) + && Duration.between(now, request.callerDeadline()).compareTo(minimumCallerBudget) >= 0; + } + + private RateLimitOutcome mapReply(RateLimitPolicy policy, RedisRateProgramReply reply) { + Objects.requireNonNull(reply, "rate program reply must be non-null"); + return switch (reply.status()) { + case ALLOWED -> evaluated(policy, reply, RedisRateProgramDecision.ALLOWED); + case DENIED -> evaluated(policy, reply, RedisRateProgramDecision.DENIED); + case DEDUP_REPLAY -> evaluated(policy, reply, reply.decision()); + case CLOCK_UNSAFE -> + unavailable(policy.policyId(), RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE); + case STATE_INCOMPATIBLE -> + incompatible(policy.policyId(), RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE); + case INVALID -> + incompatible( + policy.policyId(), RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE); + }; + } + + private RateLimitOutcome evaluated( + RateLimitPolicy policy, RedisRateProgramReply reply, RedisRateProgramDecision decision) { + if (decision == RedisRateProgramDecision.NONE) { + return incompatible( + policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE); + } + boolean allowed = decision == RedisRateProgramDecision.ALLOWED; + long expectedLimit = limit(policy); + if (reply.limit() != expectedLimit + || reply.remaining() > reply.limit() + || reply.effectiveNowMillis() < reply.serverNowMillis() + || (allowed && reply.retryAfterMillis() != 0) + || (!allowed && reply.retryAfterMillis() < 1) + || reply.resetAtMillis() < reply.effectiveNowMillis()) { + return incompatible( + policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE); + } + RateLimitDecision.DecisionCertainty certainty = + policy.algorithm() == RateLimitAlgorithm.SLIDING_COUNTER + ? RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM + : RateLimitDecision.DecisionCertainty.CERTAIN; + try { + return new RateLimitOutcome.Evaluated( + new RateLimitDecision( + allowed, + reply.limit(), + reply.remaining(), + Duration.ofMillis(reply.retryAfterMillis()), + Instant.ofEpochMilli(reply.resetAtMillis()), + policy.policyId(), + policy.policyRevision(), + RateLimitDecision.DecisionSource.GLOBAL_REDIS, + certainty)); + } catch (RuntimeException exception) { + return incompatible( + policy.policyId(), RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE); + } + } + + private RateLimitOutcome mapCommandFailure( + String policyId, RedisCommandFailureException exception) { + if (exception.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) { + return new RateLimitOutcome.Indeterminate(policyId, failureRetryAfter); + } + RateLimitOutcome.UnavailableCategory category = + exception.kind() == RedisCommandFailureException.Kind.OVERLOADED + ? RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED + : RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND; + return unavailable(policyId, category); + } + + private RateLimitOutcome unavailable( + String policyId, RateLimitOutcome.UnavailableCategory category) { + return new RateLimitOutcome.Unavailable(policyId, failureRetryAfter, category); + } + + private static RateLimitOutcome incompatible( + String policyId, RateLimitOutcome.IncompatibleCategory category) { + return new RateLimitOutcome.Incompatible(policyId, category); + } + + private static RedisCapabilityObserver.Classification classify(RateLimitOutcome outcome) { + if (outcome instanceof RateLimitOutcome.Evaluated evaluated) { + return classification( + evaluated.decision().allowed() + ? RedisCapabilityObservationEvent.Outcome.SUCCESS + : RedisCapabilityObservationEvent.Outcome.DENIED, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + if (outcome instanceof RateLimitOutcome.Indeterminate) { + return classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + if (outcome instanceof RateLimitOutcome.Incompatible) { + return classification( + RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + RateLimitOutcome.Unavailable unavailable = (RateLimitOutcome.Unavailable) outcome; + return classification( + unavailable.category() == RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED + ? RedisCapabilityObservationEvent.Outcome.OVERLOADED + : RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static RedisCapabilityObserver.Classification classification( + RedisCapabilityObservationEvent.Outcome outcome, + RedisCapabilityObservationEvent.Certainty certainty) { + return new RedisCapabilityObserver.Classification(outcome, certainty); + } + + private ProgramInvocation invocation(RateLimitPolicy policy, RateLimitRequest request) { + String algorithm = algorithmId(policy.algorithm()); + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + hashKeyVersion, + hmacSecret, + List.of( + utf8(policy.policyId()), + utf8(policy.policyRevision()), + utf8(algorithm), + utf8(request.subjectDigest()))); + List keys = + List.of( + physicalKey(policy, digest, "state"), + physicalKey(policy, digest, "dedup"), + physicalKey(policy, digest, "dedup-order")); + String evaluationId = + policy.evaluationDedupPolicy().enabled() && !request.evaluationId().isEmpty() + ? request.evaluationId() + : "-"; + List arguments = + switch (policy.parameters()) { + case RateParameters.FixedWindow fixed -> + commonArguments(policy, request.cost(), fixed.limit(), fixed.window(), evaluationId); + case RateParameters.SlidingCounter sliding -> + commonArguments( + policy, request.cost(), sliding.limit(), sliding.window(), evaluationId); + case RateParameters.TokenBucket token -> + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + utf8(policy.policyRevision()), + ascii(Math.multiplyExact(token.capacity(), SCALE)), + ascii(Math.multiplyExact(token.refillTokens(), SCALE)), + ascii(token.refillPeriod().toMillis()), + ascii(Math.multiplyExact(request.cost(), SCALE)), + ascii(policy.cleanupGrace().toMillis()), + ascii(policy.maximumClockRegression().toMillis()), + utf8(evaluationId), + ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()), + ascii(policy.evaluationDedupPolicy().maximumEntries()), + ascii(policy.evaluationDedupPolicy().maximumStoredBytes())); + }; + RedisProgramId programId = + switch (policy.algorithm()) { + case FIXED_WINDOW -> RedisProgramId.RATE_FIXED_WINDOW_V2; + case SLIDING_COUNTER -> RedisProgramId.RATE_SLIDING_COUNTER_V2; + case TOKEN_BUCKET -> RedisProgramId.RATE_TOKEN_BUCKET_V2; + }; + return new ProgramInvocation(programId, keys, arguments); + } + + private static List commonArguments( + RateLimitPolicy policy, long cost, long limit, Duration window, String evaluationId) { + return List.of( + ascii(PROGRAM_SCHEMA_VERSION), + utf8(policy.policyRevision()), + ascii(limit), + ascii(cost), + ascii(window.toMillis()), + ascii(policy.cleanupGrace().toMillis()), + ascii(policy.maximumClockRegression().toMillis()), + utf8(evaluationId), + ascii(policy.evaluationDedupPolicy().timeToLive().toMillis()), + ascii(policy.evaluationDedupPolicy().maximumEntries()), + ascii(policy.evaluationDedupPolicy().maximumStoredBytes())); + } + + private byte[] physicalKey(RateLimitPolicy policy, RedisKeyDigest digest, String kind) { + return utf8(RedisKeyBuilder.build(namespace(policy, kind), digest)); + } + + private RedisKeyNamespace namespace(RateLimitPolicy policy, String kind) { + return new RedisKeyNamespace( + application, + environment, + "rate", + policy.policyId(), + hashKeyVersion, + keyVersion, + kind, + MAXIMUM_KEY_BYTES); + } + + private static void validateProviderBounds(RateLimitPolicy policy) { + if (policy.cleanupGrace().compareTo(MAXIMUM_GRACE) > 0 + || policy.maximumClockRegression().compareTo(MAXIMUM_CLOCK_REGRESSION) > 0) { + throw new IllegalArgumentException("rate-limit grace or clock regression exceeds v2 bounds"); + } + switch (policy.parameters()) { + case RateParameters.FixedWindow fixed -> { + boundedLimitAndWindow(fixed.limit(), fixed.window()); + } + case RateParameters.SlidingCounter sliding -> { + boundedLimitAndWindow(sliding.limit(), sliding.window()); + } + case RateParameters.TokenBucket token -> { + if (token.capacity() > MAXIMUM_LIMIT + || token.refillPeriod().compareTo(MAXIMUM_WINDOW) > 0) { + throw new IllegalArgumentException("token-bucket policy exceeds v2 program bounds"); + } + } + } + } + + private static void boundedLimitAndWindow(long limit, Duration window) { + if (limit > MAXIMUM_LIMIT || window.compareTo(MAXIMUM_WINDOW) > 0) { + throw new IllegalArgumentException("rate-limit policy exceeds v2 program bounds"); + } + } + + private static long limit(RateLimitPolicy policy) { + return switch (policy.parameters()) { + case RateParameters.FixedWindow fixed -> fixed.limit(); + case RateParameters.SlidingCounter sliding -> sliding.limit(); + case RateParameters.TokenBucket token -> token.capacity(); + }; + } + + private static String algorithmId(RateLimitAlgorithm algorithm) { + return switch (algorithm) { + case FIXED_WINDOW -> "fixed-window"; + case SLIDING_COUNTER -> "sliding-window-counter"; + case TOKEN_BUCKET -> "token-bucket"; + }; + } + + private static byte[] ascii(long value) { + return Long.toString(value).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + static final class ProgramInvocation implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final List keys; + private final List arguments; + + private ProgramInvocation(RedisProgramId programId, List keys, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.keys = + Objects.requireNonNull(keys, "keys must be non-null").stream() + .map(byte[]::clone) + .toList(); + this.arguments = + Objects.requireNonNull(arguments, "arguments must be non-null").stream() + .map(byte[]::clone) + .toList(); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return RedisCatalogProgramInvocation.ReplyShape.MULTI; + } + + @Override + public List copyKeys() { + return keys.stream().map(byte[]::clone).toList(); + } + + @Override + public List copyArguments() { + return arguments.stream().map(byte[]::clone).toList(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfig.java new file mode 100644 index 0000000..bd48737 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfig.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.application.lease.DistributedLeasePort; +import java.time.Clock; +import java.util.Arrays; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Canonical COORDINATION-role composition for the owner-safe Redis efficiency lease. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(RedisLeaseSettings.class) +@ConditionalOnProperty( + name = "ca-skeleton.capabilities.lease.provider", + havingValue = "redis", + matchIfMissing = false) +public class RedisEfficiencyLeaseConfig { + + @Bean(name = "distributedLeasePort", destroyMethod = "close") + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.lease.provider", + havingValue = "redis", + matchIfMissing = false) + DistributedLeasePort distributedLeasePort( + RedisLeaseSettings settings, + RedisCanonicalRoleRegistry roleRegistry, + RedisCredentialMaterialProvider credentialProvider, + ObjectProvider clockProvider, + ObjectProvider observationsProvider) { + settings.validateActive(); + Clock clock = clockProvider.getIfAvailable(Clock::systemUTC); + RedisCapabilityObservationPort observations = + observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance); + byte[] hmacSecret = + RedisHmacMaterialResolver.resolve( + settings.keyHmacSecretReference(), credentialProvider, clock, "efficiency-lease"); + try { + return RedisEfficiencyLeaseProvider.create( + settings.namespaceApplication(), + settings.namespaceEnvironment(), + settings.hashKeyVersion(), + settings.keyVersion(), + hmacSecret, + roleRegistry.router(RedisRole.COORDINATION), + clock, + settings.driftBudget(), + observations); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseHandle.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseHandle.java new file mode 100644 index 0000000..13d84c8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseHandle.java @@ -0,0 +1,394 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.lease.LeaseAttempt; +import dev.caskeleton.application.lease.LeaseHandle; +import dev.caskeleton.application.lease.LeaseReleaseOutcome; +import dev.caskeleton.application.lease.LeaseRenewOutcome; +import dev.caskeleton.application.lease.LeaseState; +import dev.caskeleton.application.lease.LeaseUnavailableCategory; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; + +/** Thread-safe local validity handle for one Redis efficiency lease. */ +final class RedisEfficiencyLeaseHandle implements LeaseHandle { + + private static final int PROGRAM_SCHEMA_VERSION = 1; + private static final Duration MAXIMUM_LEASE = Duration.ofHours(24); + + private final byte[] key; + private final LeaseAttempt attempt; + private final RedisLeaseProgramExecutor programs; + private final RedisLeaseLifecycle lifecycle; + private final LongSupplier nanoTime; + private final long driftNanos; + private final Instant acquiredAt; + private final AtomicLong validityDeadlineNanos = new AtomicLong(); + private final AtomicReference observedServerExpiry; + private final AtomicReference state = new AtomicReference<>(LeaseState.ACTIVE); + private final Object mutationMonitor = new Object(); + private final RedisCapabilityObserver observer; + + RedisEfficiencyLeaseHandle( + byte[] key, + LeaseAttempt attempt, + RedisLeaseProgramExecutor programs, + RedisLeaseLifecycle lifecycle, + LongSupplier nanoTime, + Duration driftBudget, + Instant acquiredAt, + RedisLeaseProgramReply reply, + long commandStartedNanos, + long commandFinishedNanos, + RedisCapabilityObserver observer) { + this.key = Objects.requireNonNull(key, "key must be non-null").clone(); + this.attempt = Objects.requireNonNull(attempt, "attempt must be non-null"); + this.programs = Objects.requireNonNull(programs, "programs must be non-null"); + this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle must be non-null"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null"); + this.driftNanos = Objects.requireNonNull(driftBudget, "driftBudget must be non-null").toNanos(); + this.acquiredAt = Objects.requireNonNull(acquiredAt, "acquiredAt must be non-null"); + this.observedServerExpiry = + new AtomicReference<>(Instant.ofEpochMilli(reply.serverExpiryMillis())); + this.observer = Objects.requireNonNull(observer, "observer must be non-null"); + updateValidity(reply.remainingMillis(), commandStartedNanos, commandFinishedNanos); + } + + @Override + public String ownerToken() { + return attempt.ownerToken(); + } + + @Override + public String operationId() { + return attempt.operationId(); + } + + @Override + public Instant acquiredAt() { + return acquiredAt; + } + + @Override + public Duration remainingValidity() { + if (state.get() != LeaseState.ACTIVE) { + return Duration.ZERO; + } + long remaining = validityDeadlineNanos.get() - nanoTime.getAsLong(); + if (remaining <= 0) { + state.compareAndSet(LeaseState.ACTIVE, LeaseState.LOST); + return Duration.ZERO; + } + return Duration.ofNanos(remaining); + } + + @Override + public Instant observedServerExpiry() { + return observedServerExpiry.get(); + } + + @Override + public LeaseState state() { + remainingValidity(); + return state.get(); + } + + @Override + public LeaseRenewOutcome renew(Duration leaseTtl) { + Objects.requireNonNull(leaseTtl, "leaseTtl must be non-null"); + if (leaseTtl.isZero() + || leaseTtl.isNegative() + || leaseTtl.compareTo(MAXIMUM_LEASE) > 0 + || !Duration.ofMillis(leaseTtl.toMillis()).equals(leaseTtl)) { + throw new IllegalArgumentException("leaseTtl must be positive, bounded, whole milliseconds"); + } + return observer.observe( + RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.LEASE_RENEW, + () -> renewOpen(leaseTtl), + RedisEfficiencyLeaseHandle::classifyRenew); + } + + private LeaseRenewOutcome renewOpen(Duration leaseTtl) { + synchronized (mutationMonitor) { + LeaseState current = state(); + if (current == LeaseState.RELEASED || current == LeaseState.LOST) { + return new LeaseRenewOutcome.Absent(); + } + if (current == LeaseState.UNKNOWN) { + return new LeaseRenewOutcome.Indeterminate(attempt.operationId()); + } + long started = nanoTime.getAsLong(); + RedisLeaseProgramReply reply; + try { + reply = + lifecycle.withOpen( + () -> + programs.execute( + new ProgramInvocation( + RedisProgramId.LEASE_RENEW_V1, + key, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(attempt.ownerToken()), + ascii(attempt.operationId()), + ascii(leaseTtl.toMillis()))))); + } catch (RedisCommandFailureException failure) { + state.set(LeaseState.UNKNOWN); + return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? new LeaseRenewOutcome.Indeterminate(attempt.operationId()) + : new LeaseRenewOutcome.Unavailable(category(failure)); + } catch (RedisProgramCompatibilityException | IllegalArgumentException failure) { + state.set(LeaseState.UNKNOWN); + return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } catch (IllegalStateException failure) { + state.set(LeaseState.UNKNOWN); + return new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + long finished = nanoTime.getAsLong(); + return mapRenew(reply, started, finished); + } + } + + private LeaseRenewOutcome mapRenew(RedisLeaseProgramReply reply, long started, long finished) { + return switch (reply.status()) { + case "RENEWED" -> { + if (!validLiveReply(reply)) { + state.set(LeaseState.UNKNOWN); + yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + observedServerExpiry.set(Instant.ofEpochMilli(reply.serverExpiryMillis())); + if (!updateValidity(reply.remainingMillis(), started, finished)) { + yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED); + } + yield new LeaseRenewOutcome.Renewed(remainingValidity()); + } + case "ABSENT" -> { + state.set(LeaseState.LOST); + yield new LeaseRenewOutcome.Absent(); + } + case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> { + state.set(LeaseState.LOST); + yield new LeaseRenewOutcome.NotOwner(); + } + case "STATE_INCOMPATIBLE", "INVALID" -> { + state.set(LeaseState.UNKNOWN); + yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + default -> { + state.set(LeaseState.UNKNOWN); + yield new LeaseRenewOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + }; + } + + @Override + public LeaseReleaseOutcome release() { + return observer.observe( + RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.LEASE_RELEASE, + this::releaseOpen, + RedisEfficiencyLeaseHandle::classifyRelease); + } + + private LeaseReleaseOutcome releaseOpen() { + synchronized (mutationMonitor) { + if (state.get() == LeaseState.RELEASED) { + return new LeaseReleaseOutcome.AlreadyAbsent(); + } + RedisLeaseProgramReply reply; + try { + reply = + lifecycle.withOpen( + () -> + programs.execute( + new ProgramInvocation( + RedisProgramId.LEASE_RELEASE_V1, + key, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(attempt.ownerToken()), + ascii(attempt.operationId()))))); + } catch (RedisCommandFailureException failure) { + state.set(LeaseState.UNKNOWN); + return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? new LeaseReleaseOutcome.Indeterminate(attempt.operationId()) + : new LeaseReleaseOutcome.Unavailable(category(failure)); + } catch (RedisProgramCompatibilityException | IllegalArgumentException failure) { + state.set(LeaseState.UNKNOWN); + return new LeaseReleaseOutcome.Unavailable( + LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } catch (IllegalStateException failure) { + state.set(LeaseState.UNKNOWN); + return new LeaseReleaseOutcome.Unavailable( + LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + return mapRelease(reply); + } + } + + private LeaseReleaseOutcome mapRelease(RedisLeaseProgramReply reply) { + return switch (reply.status()) { + case "RELEASED" -> { + state.set(LeaseState.RELEASED); + yield new LeaseReleaseOutcome.Released(); + } + case "ALREADY_ABSENT" -> { + state.set(LeaseState.RELEASED); + yield new LeaseReleaseOutcome.AlreadyAbsent(); + } + case "NOT_OWNER", "OWNER_OPERATION_CONFLICT" -> { + state.set(LeaseState.LOST); + yield new LeaseReleaseOutcome.NotOwner(); + } + case "STATE_INCOMPATIBLE", "INVALID" -> { + state.set(LeaseState.UNKNOWN); + yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + default -> { + state.set(LeaseState.UNKNOWN); + yield new LeaseReleaseOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + }; + } + + private boolean updateValidity(long remainingMillis, long started, long finished) { + long commandElapsed = Math.max(0L, finished - started); + long rawValidity; + try { + rawValidity = Math.multiplyExact(remainingMillis, 1_000_000L); + } catch (ArithmeticException failure) { + state.set(LeaseState.UNKNOWN); + return false; + } + long effective = rawValidity - commandElapsed - driftNanos; + if (effective <= 0) { + validityDeadlineNanos.set(finished); + state.set(LeaseState.LOST); + return false; + } + validityDeadlineNanos.set(saturatedAdd(finished, effective)); + state.set(LeaseState.ACTIVE); + return true; + } + + private boolean validLiveReply(RedisLeaseProgramReply reply) { + return reply.remainingMillis() > 0 + && reply.remainingMillis() <= MAXIMUM_LEASE.toMillis() + && reply.stateRevision() > 0 + && reply.serverExpiryMillis() >= reply.serverNowMillis() + && reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis() + && attempt.operationId().equals(reply.operationId()); + } + + private static LeaseUnavailableCategory category(RedisCommandFailureException failure) { + return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED + ? LeaseUnavailableCategory.ADMISSION_REJECTED + : LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND; + } + + private static long saturatedAdd(long left, long right) { + try { + return Math.addExact(left, right); + } catch (ArithmeticException failure) { + return Long.MAX_VALUE; + } + } + + private static RedisCapabilityObserver.Classification classifyRenew(LeaseRenewOutcome outcome) { + if (outcome instanceof LeaseRenewOutcome.Renewed) { + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + if (outcome instanceof LeaseRenewOutcome.NotOwner) { + return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + } + if (outcome instanceof LeaseRenewOutcome.Absent) { + return definite(RedisCapabilityObservationEvent.Outcome.MISS); + } + if (outcome instanceof LeaseRenewOutcome.Indeterminate) { + return indeterminate(); + } + return unavailable(); + } + + private static RedisCapabilityObserver.Classification classifyRelease( + LeaseReleaseOutcome outcome) { + if (outcome instanceof LeaseReleaseOutcome.Released + || outcome instanceof LeaseReleaseOutcome.AlreadyAbsent) { + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + if (outcome instanceof LeaseReleaseOutcome.NotOwner) { + return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + } + if (outcome instanceof LeaseReleaseOutcome.Indeterminate) { + return indeterminate(); + } + return unavailable(); + } + + private static RedisCapabilityObserver.Classification definite( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + + private static RedisCapabilityObserver.Classification indeterminate() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + + private static RedisCapabilityObserver.Classification unavailable() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static byte[] ascii(long value) { + return Long.toString(value).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + static final class ProgramInvocation implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final byte[] key; + private final List arguments; + + private ProgramInvocation(RedisProgramId programId, byte[] key, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.key = Objects.requireNonNull(key, "key must be non-null").clone(); + this.arguments = arguments.stream().map(byte[]::clone).toList(); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return RedisCatalogProgramInvocation.ReplyShape.MULTI; + } + + @Override + public List copyKeys() { + return List.of(key.clone()); + } + + @Override + public List copyArguments() { + return arguments.stream().map(byte[]::clone).toList(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProvider.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProvider.java new file mode 100644 index 0000000..3b845a7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProvider.java @@ -0,0 +1,503 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.lease.DistributedLeasePort; +import dev.caskeleton.application.lease.LeaseAcquireOutcome; +import dev.caskeleton.application.lease.LeaseAttempt; +import dev.caskeleton.application.lease.LeaseInspectionOutcome; +import dev.caskeleton.application.lease.LeaseInspectionRequest; +import dev.caskeleton.application.lease.LeaseRequest; +import dev.caskeleton.application.lease.LeaseUnavailableCategory; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.LongSupplier; + +/** + * Redis owner-safe efficiency lease provider. + * + *

This provider has no fencing token and must not authorize correctness-sensitive writes. + */ +final class RedisEfficiencyLeaseProvider implements DistributedLeasePort, AutoCloseable { + + private static final int PROGRAM_SCHEMA_VERSION = 1; + private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5); + + private final RedisLeaseKeyFactory keys; + private final RedisLeaseProgramExecutor programs; + private final RedisLeaseTokenGenerator tokens; + private final Clock clock; + private final LongSupplier nanoTime; + private final Duration driftBudget; + private final RedisLeaseWaitStrategy waitStrategy; + private final RedisLeaseLifecycle lifecycle = new RedisLeaseLifecycle(); + private final RedisCapabilityObserver observer; + + RedisEfficiencyLeaseProvider( + RedisLeaseKeyFactory keys, + RedisLeaseProgramExecutor programs, + RedisLeaseTokenGenerator tokens, + Clock clock, + LongSupplier nanoTime, + Duration driftBudget, + RedisLeaseWaitStrategy waitStrategy) { + this( + keys, + programs, + tokens, + clock, + nanoTime, + driftBudget, + waitStrategy, + NoOpRedisCapabilityObservationPort.instance()); + } + + RedisEfficiencyLeaseProvider( + RedisLeaseKeyFactory keys, + RedisLeaseProgramExecutor programs, + RedisLeaseTokenGenerator tokens, + Clock clock, + LongSupplier nanoTime, + Duration driftBudget, + RedisLeaseWaitStrategy waitStrategy, + RedisCapabilityObservationPort observations) { + this.keys = Objects.requireNonNull(keys, "keys must be non-null"); + this.programs = Objects.requireNonNull(programs, "programs must be non-null"); + this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null"); + this.driftBudget = Objects.requireNonNull(driftBudget, "driftBudget must be non-null"); + if (driftBudget.isNegative() + || driftBudget.compareTo(Duration.ofSeconds(5)) > 0 + || !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) { + throw new IllegalArgumentException("driftBudget must be non-negative, bounded milliseconds"); + } + this.waitStrategy = Objects.requireNonNull(waitStrategy, "waitStrategy must be non-null"); + this.observer = new RedisCapabilityObserver(observations, nanoTime); + } + + static RedisEfficiencyLeaseProvider create( + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret, + RedisStructuredCommands commands, + Clock clock, + Duration driftBudget) { + return create( + application, + environment, + hashKeyVersion, + keyVersion, + hmacSecret, + commands, + clock, + driftBudget, + NoOpRedisCapabilityObservationPort.instance()); + } + + static RedisEfficiencyLeaseProvider create( + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret, + RedisStructuredCommands commands, + Clock clock, + Duration driftBudget, + RedisCapabilityObservationPort observations) { + RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease(); + return new RedisEfficiencyLeaseProvider( + new RedisLeaseKeyFactory(application, environment, hashKeyVersion, keyVersion, hmacSecret), + new RedisLeaseProgramExecutor(catalog, commands), + new RedisLeaseTokenGenerator(new SecureRandom()), + clock, + System::nanoTime, + driftBudget, + RedisLeaseWaitStrategy.parking(), + observations); + } + + @Override + public LeaseAttempt newAttempt(String operationId) { + return lifecycle.withOpen(() -> new LeaseAttempt(tokens.next(), operationId)); + } + + @Override + public LeaseAcquireOutcome tryAcquire(LeaseRequest request) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.LEASE_ACQUIRE, + () -> tryAcquireOpen(request), + RedisEfficiencyLeaseProvider::classifyAcquire); + } + + private LeaseAcquireOutcome tryAcquireOpen(LeaseRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + byte[] key; + try { + key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest())); + } catch (IllegalStateException failure) { + return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + long waitStarted = nanoTime.getAsLong(); + int backoffAttempt = 0; + while (true) { + long commandStarted = nanoTime.getAsLong(); + RedisLeaseProgramReply reply; + try { + reply = + lifecycle.withOpen( + () -> + programs.execute( + new ProgramInvocation( + RedisProgramId.LEASE_ACQUIRE_V1, + key, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(request.attempt().ownerToken()), + ascii(request.attempt().operationId()), + ascii(request.leaseTtl().toMillis()))))); + } catch (RedisCommandFailureException failure) { + return mapAcquireFailure(request, failure); + } catch (RedisProgramCompatibilityException | IllegalArgumentException failure) { + return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } catch (IllegalStateException failure) { + return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + long commandFinished = nanoTime.getAsLong(); + LeaseAcquireOutcome mapped = mapAcquire(request, key, reply, commandStarted, commandFinished); + long elapsedWaitNanos = Math.max(0L, commandFinished - waitStarted); + if (!(mapped instanceof LeaseAcquireOutcome.Contended contended) + || request.waitTimeout().isZero() + || elapsedWaitNanos >= request.waitTimeout().toNanos()) { + return mapped; + } + long remainingWaitNanos = request.waitTimeout().toNanos() - elapsedWaitNanos; + Duration pause = + pause( + contended.retryAfter(), + Duration.ofNanos(Math.max(0L, remainingWaitNanos)), + backoffAttempt++); + if (pause.isZero()) { + return mapped; + } + try { + waitStrategy.await(pause); + } catch (RedisLeaseWaitInterruptedException interrupted) { + return unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED); + } + } + } + + private LeaseAcquireOutcome mapAcquire( + LeaseRequest request, + byte[] key, + RedisLeaseProgramReply reply, + long commandStarted, + long commandFinished) { + return switch (reply.status()) { + case "ACQUIRED" -> { + if (!validOwnedReply(request.attempt(), request.leaseTtl(), reply)) { + yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + RedisEfficiencyLeaseHandle handle = + handle(request.attempt(), key, reply, commandStarted, commandFinished); + if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) { + handle.release(); + yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED); + } + yield new LeaseAcquireOutcome.Acquired(handle); + } + case "REPLAYED_SAME_OPERATION" -> { + if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) { + yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + RedisEfficiencyLeaseHandle handle = + handle(request.attempt(), key, reply, commandStarted, commandFinished); + if (handle.state() != dev.caskeleton.application.lease.LeaseState.ACTIVE) { + handle.release(); + yield unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED); + } + yield new LeaseAcquireOutcome.ReplayedSameOperation(handle); + } + case "CONTENDED" -> { + if (!validLiveReply(reply)) { + yield unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + yield new LeaseAcquireOutcome.Contended( + Duration.ofMillis(Math.min(reply.remainingMillis(), MAXIMUM_RETRY_AFTER.toMillis()))); + } + case "OWNER_OPERATION_CONFLICT" -> + validLiveReply(reply) + ? new LeaseAcquireOutcome.OwnerOperationConflict() + : unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + case "STATE_INCOMPATIBLE", "INVALID" -> + unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + default -> unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + }; + } + + @Override + public LeaseInspectionOutcome inspect(LeaseInspectionRequest request) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.EFFICIENCY_LEASE, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.LEASE_INSPECT, + () -> inspectOpen(request), + RedisEfficiencyLeaseProvider::classifyInspection); + } + + private LeaseInspectionOutcome inspectOpen(LeaseInspectionRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + byte[] key; + try { + key = lifecycle.withOpen(() -> keys.physicalKey(request.purpose(), request.resourceDigest())); + } catch (IllegalStateException failure) { + return new LeaseInspectionOutcome.Unavailable( + LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + long started = nanoTime.getAsLong(); + RedisLeaseProgramReply reply; + try { + reply = + lifecycle.withOpen( + () -> + programs.execute( + new ProgramInvocation( + RedisProgramId.LEASE_INSPECT_V1, + key, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(request.attempt().ownerToken()), + ascii(request.attempt().operationId()))))); + } catch (RedisCommandFailureException failure) { + return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? new LeaseInspectionOutcome.Indeterminate(request.attempt().operationId()) + : new LeaseInspectionOutcome.Unavailable(category(failure)); + } catch (RedisProgramCompatibilityException | IllegalArgumentException failure) { + return new LeaseInspectionOutcome.Unavailable( + LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } catch (IllegalStateException failure) { + return new LeaseInspectionOutcome.Unavailable( + LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + long finished = nanoTime.getAsLong(); + return mapInspection(request, key, reply, started, finished); + } + + private LeaseInspectionOutcome mapInspection( + LeaseInspectionRequest request, + byte[] key, + RedisLeaseProgramReply reply, + long started, + long finished) { + return switch (reply.status()) { + case "OWNED" -> { + if (!validOwnedReply(request.attempt(), Duration.ofHours(24), reply)) { + yield new LeaseInspectionOutcome.Unavailable( + LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + RedisEfficiencyLeaseHandle handle = + handle(request.attempt(), key, reply, started, finished); + yield handle.state() == dev.caskeleton.application.lease.LeaseState.ACTIVE + ? new LeaseInspectionOutcome.Owned(handle) + : new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.DEADLINE_EXPIRED); + } + case "ABSENT" -> new LeaseInspectionOutcome.Absent(); + case "NOT_OWNER" -> new LeaseInspectionOutcome.NotOwner(); + case "OWNER_OPERATION_CONFLICT" -> new LeaseInspectionOutcome.OwnerOperationConflict(); + case "STATE_INCOMPATIBLE", "INVALID" -> + new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + default -> + new LeaseInspectionOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + }; + } + + private RedisEfficiencyLeaseHandle handle( + LeaseAttempt attempt, + byte[] key, + RedisLeaseProgramReply reply, + long commandStarted, + long commandFinished) { + return new RedisEfficiencyLeaseHandle( + key, + attempt, + programs, + lifecycle, + nanoTime, + driftBudget, + clock.instant(), + reply, + commandStarted, + commandFinished, + observer); + } + + private static boolean validOwnedReply( + LeaseAttempt attempt, Duration maximumTtl, RedisLeaseProgramReply reply) { + return reply.remainingMillis() > 0 + && reply.remainingMillis() <= maximumTtl.toMillis() + && reply.stateRevision() > 0 + && reply.serverExpiryMillis() >= reply.serverNowMillis() + && reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis() + && attempt.operationId().equals(reply.operationId()); + } + + private static boolean validLiveReply(RedisLeaseProgramReply reply) { + return reply.remainingMillis() > 0 + && reply.remainingMillis() <= Duration.ofHours(24).toMillis() + && reply.stateRevision() > 0 + && reply.serverExpiryMillis() >= reply.serverNowMillis() + && reply.serverExpiryMillis() - reply.serverNowMillis() == reply.remainingMillis(); + } + + private static Duration pause(Duration contention, Duration remainingWait, int backoffAttempt) { + if (remainingWait.isZero()) { + return Duration.ZERO; + } + int shift = Math.min(backoffAttempt, 7); + long capMillis = Math.min(250L, 2L << shift); + long jitterMillis = ThreadLocalRandom.current().nextLong(1L, capMillis + 1L); + long millis = + Math.min( + jitterMillis, + Math.min(Math.max(1L, contention.toMillis()), Math.max(0L, remainingWait.toMillis()))); + return millis < 1 ? Duration.ZERO : Duration.ofMillis(millis); + } + + private static LeaseAcquireOutcome mapAcquireFailure( + LeaseRequest request, RedisCommandFailureException failure) { + if (failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE) { + return new LeaseAcquireOutcome.Indeterminate(request.attempt().operationId()); + } + if (failure.kind() == RedisCommandFailureException.Kind.OVERLOADED) { + return new LeaseAcquireOutcome.Overloaded(); + } + return unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND); + } + + private static LeaseAcquireOutcome unavailable(LeaseUnavailableCategory category) { + return new LeaseAcquireOutcome.Unavailable(category); + } + + private static LeaseUnavailableCategory category(RedisCommandFailureException failure) { + return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED + ? LeaseUnavailableCategory.ADMISSION_REJECTED + : LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND; + } + + private static RedisCapabilityObserver.Classification classifyAcquire( + LeaseAcquireOutcome outcome) { + if (outcome instanceof LeaseAcquireOutcome.Acquired + || outcome instanceof LeaseAcquireOutcome.ReplayedSameOperation) { + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + if (outcome instanceof LeaseAcquireOutcome.Contended) { + return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED); + } + if (outcome instanceof LeaseAcquireOutcome.OwnerOperationConflict) { + return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + } + if (outcome instanceof LeaseAcquireOutcome.Overloaded) { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.OVERLOADED, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + if (outcome instanceof LeaseAcquireOutcome.Indeterminate) { + return indeterminate(); + } + return unavailable(); + } + + private static RedisCapabilityObserver.Classification classifyInspection( + LeaseInspectionOutcome outcome) { + if (outcome instanceof LeaseInspectionOutcome.Owned) { + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + if (outcome instanceof LeaseInspectionOutcome.Absent) { + return definite(RedisCapabilityObservationEvent.Outcome.MISS); + } + if (outcome instanceof LeaseInspectionOutcome.NotOwner + || outcome instanceof LeaseInspectionOutcome.OwnerOperationConflict) { + return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + } + if (outcome instanceof LeaseInspectionOutcome.Indeterminate) { + return indeterminate(); + } + return unavailable(); + } + + private static RedisCapabilityObserver.Classification definite( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + + private static RedisCapabilityObserver.Classification indeterminate() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + + private static RedisCapabilityObserver.Classification unavailable() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + @Override + public void close() { + lifecycle.close(keys::close); + } + + boolean destroyed() { + return lifecycle.closed() && keys.destroyed(); + } + + private static byte[] ascii(long value) { + return Long.toString(value).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + static final class ProgramInvocation implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final byte[] key; + private final List arguments; + + private ProgramInvocation(RedisProgramId programId, byte[] key, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.key = Objects.requireNonNull(key, "key must be non-null").clone(); + this.arguments = arguments.stream().map(byte[]::clone).toList(); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return RedisCatalogProgramInvocation.ReplyShape.MULTI; + } + + @Override + public List copyKeys() { + return List.of(key.clone()); + } + + @Override + public List copyArguments() { + return arguments.stream().map(byte[]::clone).toList(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoCoordinate.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoCoordinate.java new file mode 100644 index 0000000..c3846bc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoCoordinate.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Bounded WGS84 coordinate whose text form is always redacted. */ +record RedisGeoCoordinate(double longitude, double latitude) { + + RedisGeoCoordinate { + if (!Double.isFinite(longitude) + || !Double.isFinite(latitude) + || longitude < -180 + || longitude > 180 + || latitude < -85.05112878 + || latitude > 85.05112878) { + throw new IllegalArgumentException("geo coordinate exceeds descriptor bounds"); + } + if (canonical(longitude).length() > 20 || canonical(latitude).length() > 20) { + throw new IllegalArgumentException("geo coordinate exceeds canonical encoding bounds"); + } + } + + String canonicalLongitude() { + return canonical(longitude); + } + + String canonicalLatitude() { + return canonical(latitude); + } + + private static String canonical(double value) { + if (value == 0) { + return "0"; + } + return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); + } + + @Override + public String toString() { + return "RedisGeoCoordinate[redacted]"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoPrimitives.java new file mode 100644 index 0000000..1b7caeb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisGeoPrimitives.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Privacy-sensitive bounded GEO helpers; coordinates are never returned or stringified. */ +final class RedisGeoPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + private final RedisPrimitiveDescriptor admission; + + RedisGeoPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + this.admission = catalog.descriptor(RedisPrimitiveId.GEO_ADD); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.GEO_ADD).key(slot, identity); + } + + RedisPrimitiveValue member(String member) { + return RedisPrimitiveValue.utf8(member, admission.maximumMemberBytes()); + } + + RedisPrimitiveMutationResult admitOrUpdate( + RedisPrimitiveKey key, + RedisPrimitiveValue member, + RedisGeoCoordinate coordinate, + Duration initialTimeToLive) { + return executor.mutate( + RedisPrimitiveId.GEO_ADD, + List.of(key), + new RedisPrimitiveInvocation.GeoAdmissionArguments( + member, + coordinate, + RedisPrimitiveLimit.of(admission.maximumElements(), admission), + initialTimeToLive)); + } + + RedisPrimitiveReply search( + RedisPrimitiveKey key, + RedisGeoCoordinate center, + double radiusMeters, + int count, + RedisPrimitiveInvocation.GeoArguments.Sort sort) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH); + return executor.execute( + RedisPrimitiveId.GEO_SEARCH, + List.of(key), + new RedisPrimitiveInvocation.GeoArguments( + center, + RedisPrimitiveInvocation.GeoArguments.Shape.RADIUS, + radiusMeters, + 0, + RedisPrimitiveLimit.of(count, descriptor), + sort)); + } + + RedisPrimitiveReply searchBox( + RedisPrimitiveKey key, + RedisGeoCoordinate center, + double widthMeters, + double heightMeters, + int count, + RedisPrimitiveInvocation.GeoArguments.Sort sort) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.GEO_SEARCH); + return executor.execute( + RedisPrimitiveId.GEO_SEARCH, + List.of(key), + new RedisPrimitiveInvocation.GeoArguments( + center, + RedisPrimitiveInvocation.GeoArguments.Shape.BOX, + widthMeters, + heightMeters, + RedisPrimitiveLimit.of(count, descriptor), + sort)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHashPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHashPrimitives.java new file mode 100644 index 0000000..3dca0ae --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHashPrimitives.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Bounded hash helpers. Field growth is admitted atomically against descriptor capacity. */ +final class RedisHashPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + private final RedisPrimitiveDescriptor putDescriptor; + + RedisHashPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + this.putDescriptor = catalog.descriptor(RedisPrimitiveId.HASH_SET_FIELDS); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.HASH_GET).key(slot, identity); + } + + RedisPrimitiveValue field(String field) { + return RedisPrimitiveValue.utf8(field, putDescriptor.maximumFieldBytes()); + } + + RedisPrimitiveValue value(String value) { + return RedisPrimitiveValue.utf8(value, putDescriptor.maximumValueBytes()); + } + + RedisPrimitiveMutationResult put( + RedisPrimitiveKey key, + RedisPrimitiveValue field, + RedisPrimitiveValue value, + Duration initialTimeToLive) { + return executor.mutate( + RedisPrimitiveId.HASH_SET_FIELDS, + List.of(key), + new RedisPrimitiveInvocation.HashAdmissionArguments( + field, + value, + RedisPrimitiveLimit.of(putDescriptor.maximumElements(), putDescriptor), + initialTimeToLive)); + } + + RedisPrimitiveReply get(RedisPrimitiveKey key, RedisPrimitiveValue field) { + return executor.execute( + RedisPrimitiveId.HASH_GET, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(List.of(field))); + } + + RedisPrimitiveReply multiGet(RedisPrimitiveKey key, List fields) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_MGET); + RedisPrimitiveLimit.of(fields.size(), descriptor); + return executor.execute( + RedisPrimitiveId.HASH_MGET, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(fields)); + } + + RedisPrimitiveMutationResult delete(RedisPrimitiveKey key, List fields) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_DELETE_FIELDS); + RedisPrimitiveLimit.of(fields.size(), descriptor); + return executor.mutate( + RedisPrimitiveId.HASH_DELETE_FIELDS, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(fields)); + } + + RedisPrimitiveScanOutcome scan( + RedisPrimitiveKey key, RedisPrimitiveCursor cursor, long routeEpoch) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE); + cursor.validateFor(catalog, descriptor, key, routeEpoch); + return RedisPrimitiveScanOutcome.from( + executor.execute( + RedisPrimitiveId.HASH_SCAN_PAGE, + List.of(key), + new RedisPrimitiveInvocation.ScanPageArguments( + cursor, descriptor.maximumElements(), descriptor.maximumResultBytes())), + RedisPrimitiveHashEntry.class); + } + + RedisPrimitiveMutationResult compareRevision( + RedisPrimitiveKey key, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind expectedKind, + String expectedRevision, + String nextRevision, + RedisPrimitiveValue value, + Duration initialTimeToLive) { + return executor.mutate( + RedisPrimitiveId.HASH_REVISION_CAS, + List.of(key), + new RedisPrimitiveInvocation.HashRevisionArguments( + expectedKind, expectedRevision, nextRevision, value, initialTimeToLive)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHmacMaterialResolver.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHmacMaterialResolver.java new file mode 100644 index 0000000..8de187f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHmacMaterialResolver.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import java.time.Clock; +import java.util.Arrays; +import java.util.Base64; + +/** Resolves bounded Base64 HMAC material without retaining a raw configuration secret. */ +final class RedisHmacMaterialResolver { + + private RedisHmacMaterialResolver() {} + + static byte[] resolve( + String reference, + RedisCredentialMaterialProvider credentialProvider, + Clock clock, + String capability) { + try (VersionedRedisCredentialMaterial material = + credentialProvider.resolve(RedisSecretReference.parse(reference))) { + if (material.isExpiredAt(clock.instant())) { + throw failure(capability); + } + byte[] decoded = + material.useSecret( + chars -> { + byte[] encoded = new byte[chars.length]; + try { + for (int index = 0; index < chars.length; index++) { + if (chars[index] > 0x7f) { + throw failure(capability); + } + encoded[index] = (byte) chars[index]; + } + return Base64.getDecoder().decode(encoded); + } finally { + Arrays.fill(encoded, (byte) 0); + } + }); + if (decoded.length < 32 || decoded.length > 4096) { + Arrays.fill(decoded, (byte) 0); + throw failure(capability); + } + return decoded; + } catch (RuntimeException ignored) { + throw failure(capability); + } + } + + private static IllegalStateException failure(String capability) { + return new IllegalStateException("Redis " + capability + " HMAC material resolution failed"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHyperLogLogPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHyperLogLogPrimitives.java new file mode 100644 index 0000000..1ed3ead --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisHyperLogLogPrimitives.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Approximate HLL helpers forbidden for billing, authorization, quota, audit and security. */ +final class RedisHyperLogLogPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + private final RedisPrimitiveDescriptor addDescriptor; + + RedisHyperLogLogPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + this.addDescriptor = catalog.descriptor(RedisPrimitiveId.HLL_ADD); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.HLL_ADD).key(slot, identity); + } + + RedisPrimitiveValue element(String value) { + return RedisPrimitiveValue.utf8(value, addDescriptor.maximumValueBytes()); + } + + RedisPrimitiveMutationResult add(RedisPrimitiveKey key, List elements) { + RedisPrimitiveLimit.of(elements.size(), addDescriptor); + return executor.mutate( + RedisPrimitiveId.HLL_ADD, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(elements)); + } + + RedisPrimitiveReply count(RedisPrimitiveKey key) { + return executor.execute( + RedisPrimitiveId.HLL_COUNT, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE); + } + + RedisPrimitiveMutationResult merge( + RedisPrimitiveKey destination, List sources) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HLL_MERGE_SAME_SLOT); + if (sources.isEmpty() || sources.size() > descriptor.maximumKeys() - 1) { + throw new IllegalArgumentException("HLL merge fan-in exceeds descriptor bounds"); + } + ArrayList keys = new ArrayList<>(); + keys.add(destination); + keys.addAll(sources); + descriptor.validateKeys(keys); + return executor.mutate( + RedisPrimitiveId.HLL_MERGE_SAME_SLOT, keys, RedisPrimitiveInvocation.NoArguments.INSTANCE); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfig.java new file mode 100644 index 0000000..dffb43e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfig.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.application.idempotency.IdempotencyExecutorV2; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import java.security.SecureRandom; +import java.time.Clock; +import java.util.Arrays; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Canonical COORDINATION-role composition for Redis request-replay idempotency V2. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(RedisIdempotencySettings.class) +@ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "redis", + matchIfMissing = false) +public class RedisIdempotencyConfig { + + @Bean(name = "redisIdempotencyStoreV2", destroyMethod = "close") + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "redis", + matchIfMissing = false) + IdempotencyStorePortV2 redisIdempotencyStoreV2( + RedisIdempotencySettings settings, + RedisCanonicalRoleRegistry roleRegistry, + RedisCredentialMaterialProvider credentialProvider, + ObjectProvider clockProvider, + ObjectProvider observationsProvider) { + settings.validateActive(); + Clock clock = clockProvider.getIfAvailable(Clock::systemUTC); + RedisCapabilityObservationPort observations = + observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance); + byte[] hmacSecret = + RedisHmacMaterialResolver.resolve( + settings.keyHmacSecretReference(), credentialProvider, clock, "idempotency"); + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + try { + return new RedisIdempotencyStoreProvider( + new RedisIdempotencyKeyFactory( + settings.namespaceApplication(), + settings.namespaceEnvironment(), + settings.hashKeyVersion(), + settings.keyVersion(), + hmacSecret), + new RedisIdempotencyProgramExecutor(catalog, roleRegistry.router(RedisRole.COORDINATION)), + new RedisIdempotencyRecordCodec(), + new RedisIdempotencyTokenGenerator(new SecureRandom()), + observations, + System::nanoTime); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + @Bean(name = "idempotencyExecutorV2") + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "redis", + matchIfMissing = false) + IdempotencyExecutorV2 idempotencyExecutorV2( + IdempotencyStorePortV2 store, RedisIdempotencySettings settings) { + return new IdempotencyExecutorV2( + store, + settings.processingLease(), + settings.replayTtl(), + settings.failureRetention(), + settings.responseCodecId(), + settings.policyRevision()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyKeyFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyKeyFactory.java new file mode 100644 index 0000000..277ffca --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyKeyFactory.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** HMAC-pseudonymizes every request-replay scope dimension into one Cluster-safe record key. */ +final class RedisIdempotencyKeyFactory implements AutoCloseable { + + private final RedisKeyNamespace namespace; + private final byte[] hmacSecret; + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisIdempotencyKeyFactory( + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret) { + this.namespace = + new RedisKeyNamespace( + application, + environment, + "idempotency", + "request", + hashKeyVersion, + keyVersion, + "record", + 512); + this.hmacSecret = + Arrays.copyOf( + Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length); + if (this.hmacSecret.length < 32) { + throw new IllegalArgumentException( + "idempotency scope HMAC secret requires at least 32 bytes"); + } + } + + byte[] physicalKey(IdempotencyScope scope) { + if (closed.get()) { + throw new IllegalStateException("idempotency key material is closed"); + } + Objects.requireNonNull(scope, "scope must be non-null"); + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + namespace.hashKeyVersion(), + hmacSecret, + List.of( + utf8(scope.tenant() == null ? "-" : scope.tenant()), + utf8(scope.principal()), + utf8(scope.idempotencyKey()), + utf8(scope.useCaseName()))); + return utf8(RedisKeyBuilder.build(namespace, digest)); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + boolean destroyed() { + return closed.get() + && java.util.stream.IntStream.range(0, hmacSecret.length) + .allMatch(index -> hmacSecret[index] == 0); + } + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyLifecycle.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyLifecycle.java new file mode 100644 index 0000000..6de5470 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyLifecycle.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Supplier; + +/** Prevents request-replay commands from racing provider close and HMAC destruction. */ +final class RedisIdempotencyLifecycle { + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private boolean closed; + + T withOpen(Supplier operation) { + lock.readLock().lock(); + try { + if (closed) { + throw new IllegalStateException("Redis idempotency provider is closed"); + } + return operation.get(); + } finally { + lock.readLock().unlock(); + } + } + + void close(Runnable destroy) { + lock.writeLock().lock(); + try { + if (!closed) { + destroy.run(); + closed = true; + } + } finally { + lock.writeLock().unlock(); + } + } + + boolean closed() { + lock.readLock().lock(); + try { + return closed; + } finally { + lock.readLock().unlock(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramExecutor.java new file mode 100644 index 0000000..35340ce --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramExecutor.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +/** Executes and fail-closed parses the fixed six-field request-replay program protocol. */ +final class RedisIdempotencyProgramExecutor { + + private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L; + + private final RedisProgramCatalog catalog; + private final RedisStructuredCommands commands; + + RedisIdempotencyProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + } + + RedisIdempotencyProgramReply execute(RedisIdempotencyStoreProvider.ProgramInvocation material) { + RedisProgramId id = material.programId(); + RedisProgramDescriptor descriptor = catalog.descriptor(id); + List result = + RedisScriptRecovery.evalMulti(commands, catalog.capabilityInvocation(material)); + if (result == null || result.size() != 6) { + throw incompatible(id); + } + for (byte[] field : result) { + if (field == null || field.length > descriptor.maximumReplyFieldBytes()) { + throw incompatible(id); + } + } + String status = ascii(result.get(0), id); + if (!descriptor.statuses().contains(status)) { + throw new RedisProgramCompatibilityException(id, status); + } + return new RedisIdempotencyProgramReply( + status, + unsigned(result.get(1), id), + unsigned(result.get(2), id), + ascii(result.get(3), id), + ascii(result.get(4), id), + ascii(result.get(5), id)); + } + + private static String ascii(byte[] value, RedisProgramId id) { + for (byte character : value) { + if (character < 0x20 || character > 0x7e) { + throw incompatible(id); + } + } + return new String(value, StandardCharsets.US_ASCII); + } + + private static long unsigned(byte[] value, RedisProgramId id) { + String encoded = ascii(value, id); + if (!encoded.matches("0|[1-9][0-9]{0,15}")) { + throw incompatible(id); + } + try { + long parsed = Long.parseLong(encoded); + if (parsed > MAXIMUM_EXACT_LUA_INTEGER) { + throw incompatible(id); + } + return parsed; + } catch (NumberFormatException exception) { + throw incompatible(id); + } + } + + private static RedisProgramCompatibilityException incompatible(RedisProgramId id) { + return new RedisProgramCompatibilityException(id, ""); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramReply.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramReply.java new file mode 100644 index 0000000..5acd05c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramReply.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Six-field bounded reply shared by all request-replay programs. */ +record RedisIdempotencyProgramReply( + String status, + long attempt, + long expiresAtMillis, + String payload, + String digest, + String operationId) {} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodec.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodec.java new file mode 100644 index 0000000..26fc2d4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodec.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.idempotency.StoredResponse; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Objects; + +/** Bounded UTF-8/Base64URL response codec used by the Redis request-replay programs. */ +final class RedisIdempotencyRecordCodec { + + static final int MAXIMUM_PAYLOAD_BYTES = 8_192; + static final int MAXIMUM_ENCODED_BYTES = 10_924; + private static final HexFormat HEX = HexFormat.of(); + + EncodedResponse encode(StoredResponse response) { + Objects.requireNonNull(response, "response must be non-null"); + byte[] payload = strictUtf8(response.payload()); + if (payload.length > MAXIMUM_PAYLOAD_BYTES) { + throw new IllegalArgumentException("idempotency response exceeds the Redis payload bound"); + } + String encoded = + payload.length == 0 ? "-" : Base64.getUrlEncoder().withoutPadding().encodeToString(payload); + return new EncodedResponse(encoded, sha256(payload)); + } + + StoredResponse decode(String encodedPayload, String expectedDigest) { + Objects.requireNonNull(encodedPayload, "encodedPayload must be non-null"); + if (expectedDigest == null || !expectedDigest.matches("[0-9a-f]{64}")) { + throw new RedisProgramCompatibilityException( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, ""); + } + if (encodedPayload.length() > MAXIMUM_ENCODED_BYTES) { + throw new RedisProgramCompatibilityException( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, ""); + } + byte[] decoded; + try { + decoded = + "-".equals(encodedPayload) ? new byte[0] : Base64.getUrlDecoder().decode(encodedPayload); + } catch (IllegalArgumentException exception) { + throw new RedisProgramCompatibilityException( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, ""); + } + if (decoded.length > MAXIMUM_PAYLOAD_BYTES) { + throw new RedisProgramCompatibilityException( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, ""); + } + if (!MessageDigest.isEqual( + expectedDigest.getBytes(StandardCharsets.US_ASCII), + sha256(decoded).getBytes(StandardCharsets.US_ASCII))) { + throw new RedisProgramCompatibilityException( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, ""); + } + try { + return new StoredResponse( + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(decoded)) + .toString()); + } catch (CharacterCodingException exception) { + throw new RedisProgramCompatibilityException( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, ""); + } + } + + private static byte[] strictUtf8(String value) { + try { + ByteBuffer encoded = + StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + byte[] result = new byte[encoded.remaining()]; + encoded.get(result); + return result; + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException("idempotency response is not valid Unicode", exception); + } + } + + private static String sha256(byte[] value) { + try { + return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + record EncodedResponse(String payload, String digest) { + + EncodedResponse { + Objects.requireNonNull(payload, "payload must be non-null"); + if (payload.isEmpty() || payload.length() > MAXIMUM_ENCODED_BYTES) { + throw new IllegalArgumentException("encoded idempotency response is out of bounds"); + } + if (digest == null || !digest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("idempotency response digest is invalid"); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettings.java new file mode 100644 index 0000000..6ec9008 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettings.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Canonical Redis request-replay policy, separate from topology and credential material. */ +@ConfigurationProperties(prefix = "ca-skeleton.capabilities.idempotency") +public record RedisIdempotencySettings( + String provider, + String keyHmacSecretReference, + String namespaceApplication, + String namespaceEnvironment, + int hashKeyVersion, + int keyVersion, + Duration processingLease, + Duration replayTtl, + Duration failureRetention, + String responseCodecId, + String policyRevision) { + + @ConstructorBinding + public RedisIdempotencySettings { + provider = provider == null ? "" : provider.trim(); + keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim(); + namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); + namespaceEnvironment = defaultText(namespaceEnvironment, "local"); + hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion; + keyVersion = keyVersion == 0 ? 1 : keyVersion; + processingLease = processingLease == null ? Duration.ofSeconds(30) : processingLease; + replayTtl = replayTtl == null ? Duration.ofHours(24) : replayTtl; + failureRetention = failureRetention == null ? Duration.ofHours(24) : failureRetention; + responseCodecId = defaultText(responseCodecId, "json-v2"); + policyRevision = defaultText(policyRevision, "request-replay-v2"); + + new RedisKeyNamespace( + namespaceApplication, + namespaceEnvironment, + "idempotency", + "validation", + hashKeyVersion, + keyVersion, + "record", + 512); + new IdempotencyClaimRequest( + IdempotencyScope.of("validation", "validation", "validation"), + new RequestFingerprint("0".repeat(64)), + new IdempotencyClaimAttempt("validation_owner", "validation_operation"), + processingLease, + replayTtl, + responseCodecId, + policyRevision); + if (failureRetention.isZero() + || failureRetention.isNegative() + || failureRetention.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException("failureRetention must be positive and at most 30 days"); + } + } + + void validateActive() { + if (!"redis".equals(provider)) { + throw new IllegalArgumentException( + "idempotency provider must be redis when this adapter is active"); + } + RedisSecretReference.parse(keyHmacSecretReference); + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProvider.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProvider.java new file mode 100644 index 0000000..8f1367c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProvider.java @@ -0,0 +1,568 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.IdempotencyFailOutcome; +import dev.caskeleton.application.idempotency.IdempotencyFailureDisposition; +import dev.caskeleton.application.idempotency.IdempotencyInspection; +import dev.caskeleton.application.idempotency.IdempotencyInspectionRequest; +import dev.caskeleton.application.idempotency.IdempotencyOwner; +import dev.caskeleton.application.idempotency.IdempotencyReleaseOutcome; +import dev.caskeleton.application.idempotency.IdempotencyRenewOutcome; +import dev.caskeleton.application.idempotency.IdempotencyStartOutcome; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.function.LongSupplier; + +/** + * Redis request-replay candidate provider. + * + *

Atomic ownership protects one Redis record only. This provider does not claim cross-store + * exactly-once behavior for business side effects. + */ +final class RedisIdempotencyStoreProvider implements IdempotencyStorePortV2, AutoCloseable { + + private static final int PROGRAM_SCHEMA_VERSION = 2; + private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5); + + private final RedisIdempotencyKeyFactory keys; + private final RedisIdempotencyProgramExecutor programs; + private final RedisIdempotencyRecordCodec responses; + private final RedisIdempotencyTokenGenerator tokens; + private final RedisIdempotencyLifecycle lifecycle = new RedisIdempotencyLifecycle(); + private final RedisCapabilityObserver observer; + + RedisIdempotencyStoreProvider( + RedisIdempotencyKeyFactory keys, + RedisIdempotencyProgramExecutor programs, + RedisIdempotencyRecordCodec responses, + RedisIdempotencyTokenGenerator tokens) { + this( + keys, + programs, + responses, + tokens, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisIdempotencyStoreProvider( + RedisIdempotencyKeyFactory keys, + RedisIdempotencyProgramExecutor programs, + RedisIdempotencyRecordCodec responses, + RedisIdempotencyTokenGenerator tokens, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { + this.keys = Objects.requireNonNull(keys, "keys must be non-null"); + this.programs = Objects.requireNonNull(programs, "programs must be non-null"); + this.responses = Objects.requireNonNull(responses, "responses must be non-null"); + this.tokens = Objects.requireNonNull(tokens, "tokens must be non-null"); + this.observer = new RedisCapabilityObserver(observations, ticker); + } + + @Override + public IdempotencyClaimAttempt newClaimAttempt(String operationId) { + return lifecycle.withOpen(() -> new IdempotencyClaimAttempt(tokens.next(), operationId)); + } + + @Override + public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.IDEMPOTENCY, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_CLAIM, + () -> claimOpen(request), + RedisIdempotencyStoreProvider::classifyClaim); + } + + private IdempotencyClaimOutcome claimOpen(IdempotencyClaimRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + RedisIdempotencyProgramReply reply; + try { + reply = + execute( + RedisProgramId.IDEMPOTENCY_CLAIM_V1, + request.scope(), + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(request.fingerprint().hex()), + ascii(request.claimAttempt().ownerToken()), + ascii(request.claimAttempt().operationId()), + ascii(request.processingLeaseTtl().toMillis()), + ascii(request.recoveryRetention().toMillis()), + ascii(request.responseCodecId()), + ascii(request.policyRevision()))); + } catch (RedisCommandFailureException failure) { + return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId()) + : new IdempotencyClaimOutcome.Unavailable(); + } catch (RedisProgramCompatibilityException + | IllegalArgumentException + | IllegalStateException failure) { + return new IdempotencyClaimOutcome.Unavailable(); + } + try { + return mapClaim(request, reply); + } catch (RedisProgramCompatibilityException | IllegalArgumentException failure) { + return new IdempotencyClaimOutcome.Unavailable(); + } + } + + private IdempotencyClaimOutcome mapClaim( + IdempotencyClaimRequest request, RedisIdempotencyProgramReply reply) { + return switch (reply.status()) { + case "ACQUIRED" -> + new IdempotencyClaimOutcome.Acquired( + owner(request, reply.attempt()), instant(reply.expiresAtMillis())); + case "REPLAYED_ACQUIRE" -> + new IdempotencyClaimOutcome.ReplayedAcquire( + owner(request, reply.attempt()), instant(reply.expiresAtMillis())); + case "TAKEN_OVER_CLAIMED" -> + new IdempotencyClaimOutcome.TakenOverClaimed( + owner(request, reply.attempt()), instant(reply.expiresAtMillis())); + case "COMPLETED_REPLAY" -> + new IdempotencyClaimOutcome.CompletedReplay( + responses.decode(reply.payload(), reply.digest()), instant(reply.expiresAtMillis())); + case "IN_PROGRESS" -> + new IdempotencyClaimOutcome.InProgress( + Duration.ofMillis(Math.min(reply.expiresAtMillis(), MAXIMUM_RETRY_AFTER.toMillis())), + reply.attempt()); + case "RECOVERY_REQUIRED" -> new IdempotencyClaimOutcome.RecoveryRequired(reply.attempt()); + case "FINGERPRINT_MISMATCH" -> new IdempotencyClaimOutcome.FingerprintMismatch(); + case "OWNER_OPERATION_CONFLICT" -> new IdempotencyClaimOutcome.OwnerOperationConflict(); + case "STATE_INCOMPATIBLE", "INVALID" -> new IdempotencyClaimOutcome.Unavailable(); + default -> new IdempotencyClaimOutcome.Unavailable(); + }; + } + + @Override + public IdempotencyStartOutcome markExecutionStarted(IdempotencyOwner owner, String operationId) { + Objects.requireNonNull(owner, "owner must be non-null"); + return mutation( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_START, + operationId, + RedisProgramId.IDEMPOTENCY_START_V1, + owner, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(owner.ownerToken()), + ascii(owner.attempt()), + ascii(operationId)), + reply -> + new IdempotencyStartOutcome( + IdempotencyStartOutcome.Status.valueOf(reply.status()), null), + IdempotencyStartOutcome::indeterminate, + IdempotencyStartOutcome::unavailable, + RedisIdempotencyStoreProvider::classifyStart); + } + + @Override + public IdempotencyRenewOutcome renew( + IdempotencyOwner owner, Duration processingLeaseTtl, String operationId) { + Objects.requireNonNull(owner, "owner must be non-null"); + positive(processingLeaseTtl, Duration.ofHours(24), "processingLeaseTtl"); + return mutation( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_RENEW, + operationId, + RedisProgramId.IDEMPOTENCY_RENEW_V1, + owner, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(owner.ownerToken()), + ascii(owner.attempt()), + ascii(processingLeaseTtl.toMillis()), + ascii(operationId)), + reply -> + new IdempotencyRenewOutcome( + IdempotencyRenewOutcome.Status.valueOf(reply.status()), null), + IdempotencyRenewOutcome::indeterminate, + IdempotencyRenewOutcome::unavailable, + RedisIdempotencyStoreProvider::classifyRenew); + } + + @Override + public IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId) { + Objects.requireNonNull(owner, "owner must be non-null"); + positive(replayTtl, Duration.ofDays(30), "replayTtl"); + RedisIdempotencyRecordCodec.EncodedResponse encoded = responses.encode(response); + return mutation( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_COMPLETE, + operationId, + RedisProgramId.IDEMPOTENCY_COMPLETE_V1, + owner, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(owner.ownerToken()), + ascii(owner.attempt()), + ascii(encoded.payload()), + ascii(encoded.digest()), + ascii(replayTtl.toMillis()), + ascii(operationId)), + reply -> + new IdempotencyCompleteOutcome( + IdempotencyCompleteOutcome.Status.valueOf(reply.status()), null), + IdempotencyCompleteOutcome::indeterminate, + IdempotencyCompleteOutcome::unavailable, + RedisIdempotencyStoreProvider::classifyComplete); + } + + @Override + public IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + String operationId) { + Objects.requireNonNull(owner, "owner must be non-null"); + Objects.requireNonNull(disposition, "disposition must be non-null"); + positive(retention, Duration.ofDays(30), "retention"); + return mutation( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_FAIL, + operationId, + RedisProgramId.IDEMPOTENCY_FAIL_V1, + owner, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(owner.ownerToken()), + ascii(owner.attempt()), + ascii(disposition.name()), + ascii(retention.toMillis()), + ascii(operationId)), + reply -> + new IdempotencyFailOutcome(IdempotencyFailOutcome.Status.valueOf(reply.status()), null), + IdempotencyFailOutcome::indeterminate, + IdempotencyFailOutcome::unavailable, + RedisIdempotencyStoreProvider::classifyFail); + } + + @Override + public IdempotencyReleaseOutcome releaseBeforeExecution( + IdempotencyOwner owner, String operationId) { + Objects.requireNonNull(owner, "owner must be non-null"); + return mutation( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_RELEASE, + operationId, + RedisProgramId.IDEMPOTENCY_RELEASE_V1, + owner, + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(owner.ownerToken()), + ascii(owner.attempt()), + ascii(operationId)), + reply -> + new IdempotencyReleaseOutcome( + IdempotencyReleaseOutcome.Status.valueOf(reply.status()), null), + IdempotencyReleaseOutcome::indeterminate, + IdempotencyReleaseOutcome::unavailable, + RedisIdempotencyStoreProvider::classifyRelease); + } + + @Override + public IdempotencyInspection inspect(IdempotencyInspectionRequest request) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.IDEMPOTENCY, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_INSPECT, + () -> inspectOpen(request), + RedisIdempotencyStoreProvider::classifyInspection); + } + + private IdempotencyInspection inspectOpen(IdempotencyInspectionRequest request) { + Objects.requireNonNull(request, "request must be non-null"); + RedisIdempotencyProgramReply reply; + try { + reply = + execute( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, + request.scope(), + List.of( + ascii(PROGRAM_SCHEMA_VERSION), + ascii(request.fingerprint().hex()), + ascii(request.claimAttempt().ownerToken()), + ascii(request.claimAttempt().operationId()))); + } catch (RedisCommandFailureException + | RedisProgramCompatibilityException + | IllegalStateException failure) { + return new IdempotencyInspection.Unavailable(); + } + try { + return switch (reply.status()) { + case "ABSENT" -> new IdempotencyInspection.Absent(); + case "CLAIMED_SAME_OPERATION" -> + new IdempotencyInspection.ClaimedSameOperation( + inspectionOwner(request, reply.attempt()), instant(reply.expiresAtMillis())); + case "EXECUTING_SAME_OPERATION" -> + new IdempotencyInspection.ExecutingSameOperation( + inspectionOwner(request, reply.attempt()), instant(reply.expiresAtMillis())); + case "COMPLETED_REPLAY" -> + new IdempotencyInspection.CompletedReplay( + responses.decode(reply.payload(), reply.digest()), + instant(reply.expiresAtMillis())); + case "IN_PROGRESS_OTHER" -> new IdempotencyInspection.InProgressOther(reply.attempt()); + case "FAILED_RETRYABLE" -> new IdempotencyInspection.FailedRetryable(reply.attempt()); + case "ABANDONED" -> new IdempotencyInspection.Abandoned(reply.attempt()); + case "FINGERPRINT_MISMATCH" -> new IdempotencyInspection.FingerprintMismatch(); + case "OPERATION_CONFLICT" -> new IdempotencyInspection.OperationConflict(); + case "STATE_INCOMPATIBLE", "INVALID" -> new IdempotencyInspection.Unavailable(); + default -> new IdempotencyInspection.Unavailable(); + }; + } catch (RedisProgramCompatibilityException | IllegalArgumentException failure) { + return new IdempotencyInspection.Unavailable(); + } + } + + private T mutation( + RedisCapabilityObservationEvent.Operation operation, + String operationId, + RedisProgramId program, + IdempotencyOwner owner, + List arguments, + java.util.function.Function mapper, + java.util.function.Function indeterminate, + java.util.function.Supplier unavailable, + java.util.function.Function classifier) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.IDEMPOTENCY, + RedisCapabilityObservationEvent.Role.COORDINATION, + operation, + () -> + mutationOpen( + operationId, program, owner, arguments, mapper, indeterminate, unavailable), + classifier); + } + + private T mutationOpen( + String operationId, + RedisProgramId program, + IdempotencyOwner owner, + List arguments, + java.util.function.Function mapper, + java.util.function.Function indeterminate, + java.util.function.Supplier unavailable) { + try { + RedisIdempotencyProgramReply reply = execute(program, owner.scope(), arguments); + if ("STATE_INCOMPATIBLE".equals(reply.status()) || "INVALID".equals(reply.status())) { + return unavailable.get(); + } + return mapper.apply(reply); + } catch (RedisCommandFailureException failure) { + return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? indeterminate.apply(operationId) + : unavailable.get(); + } catch (RedisProgramCompatibilityException + | IllegalArgumentException + | IllegalStateException failure) { + return unavailable.get(); + } + } + + private RedisIdempotencyProgramReply execute( + RedisProgramId program, + dev.caskeleton.application.idempotency.IdempotencyScope scope, + List arguments) { + return lifecycle.withOpen( + () -> programs.execute(new ProgramInvocation(program, keys.physicalKey(scope), arguments))); + } + + private static IdempotencyOwner owner(IdempotencyClaimRequest request, long attempt) { + return new IdempotencyOwner(request.scope(), request.claimAttempt().ownerToken(), attempt); + } + + private static IdempotencyOwner inspectionOwner( + IdempotencyInspectionRequest request, long attempt) { + return new IdempotencyOwner(request.scope(), request.claimAttempt().ownerToken(), attempt); + } + + private static Instant instant(long epochMillis) { + return Instant.ofEpochMilli(epochMillis); + } + + private static RedisCapabilityObserver.Classification classifyClaim( + IdempotencyClaimOutcome outcome) { + if (outcome instanceof IdempotencyClaimOutcome.Acquired + || outcome instanceof IdempotencyClaimOutcome.ReplayedAcquire + || outcome instanceof IdempotencyClaimOutcome.TakenOverClaimed + || outcome instanceof IdempotencyClaimOutcome.CompletedReplay) { + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + if (outcome instanceof IdempotencyClaimOutcome.InProgress + || outcome instanceof IdempotencyClaimOutcome.RecoveryRequired) { + return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED); + } + if (outcome instanceof IdempotencyClaimOutcome.FingerprintMismatch + || outcome instanceof IdempotencyClaimOutcome.OwnerOperationConflict) { + return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + } + if (outcome instanceof IdempotencyClaimOutcome.Indeterminate) { + return indeterminate(); + } + return notApplied(); + } + + private static RedisCapabilityObserver.Classification classifyInspection( + IdempotencyInspection outcome) { + if (outcome instanceof IdempotencyInspection.Unavailable) { + return notApplied(); + } + if (outcome instanceof IdempotencyInspection.FingerprintMismatch + || outcome instanceof IdempotencyInspection.OperationConflict) { + return definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + } + if (outcome instanceof IdempotencyInspection.InProgressOther) { + return definite(RedisCapabilityObservationEvent.Outcome.CONTENDED); + } + if (outcome instanceof IdempotencyInspection.Absent) { + return definite(RedisCapabilityObservationEvent.Outcome.MISS); + } + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + + static RedisCapabilityObserver.Classification classifyStart(IdempotencyStartOutcome outcome) { + return switch (outcome.status()) { + case STARTED, ALREADY_STARTED_SAME_OPERATION -> + definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS); + case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED); + case NOT_CLAIMED, OPERATION_CONFLICT -> + definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + case INDETERMINATE -> indeterminate(); + case UNAVAILABLE -> notApplied(); + }; + } + + static RedisCapabilityObserver.Classification classifyRenew(IdempotencyRenewOutcome outcome) { + return switch (outcome.status()) { + case RENEWED, ALREADY_RENEWED_SAME_OPERATION -> + definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS); + case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED); + case NOT_IN_PROGRESS, OPERATION_CONFLICT -> + definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + case INDETERMINATE -> indeterminate(); + case UNAVAILABLE -> notApplied(); + }; + } + + static RedisCapabilityObserver.Classification classifyComplete( + IdempotencyCompleteOutcome outcome) { + return switch (outcome.status()) { + case COMPLETED, ALREADY_COMPLETED_SAME_RESULT -> + definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS); + case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED); + case RESPONSE_CONFLICT, NOT_IN_PROGRESS, OPERATION_CONFLICT -> + definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + case INDETERMINATE -> indeterminate(); + case UNAVAILABLE -> notApplied(); + }; + } + + static RedisCapabilityObserver.Classification classifyFail(IdempotencyFailOutcome outcome) { + return switch (outcome.status()) { + case MARKED_RETRYABLE, MARKED_ABANDONED, ALREADY_MARKED_SAME_OPERATION -> + definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS); + case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED); + case NOT_IN_PROGRESS, OPERATION_CONFLICT -> + definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + case INDETERMINATE -> indeterminate(); + case UNAVAILABLE -> notApplied(); + }; + } + + static RedisCapabilityObserver.Classification classifyRelease(IdempotencyReleaseOutcome outcome) { + return switch (outcome.status()) { + case RELEASED_BEFORE_EXECUTION, ALREADY_RELEASED_SAME_OPERATION -> + definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.MISS); + case NOT_OWNER -> definite(RedisCapabilityObservationEvent.Outcome.DENIED); + case EXECUTION_ALREADY_STARTED, OPERATION_CONFLICT -> + definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + case INDETERMINATE -> indeterminate(); + case UNAVAILABLE -> notApplied(); + }; + } + + private static RedisCapabilityObserver.Classification definite( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + + private static RedisCapabilityObserver.Classification indeterminate() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + + private static RedisCapabilityObserver.Classification notApplied() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static void positive(Duration value, Duration maximum, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + } + + private static byte[] ascii(long value) { + return Long.toString(value).getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] ascii(String value) { + return Objects.requireNonNull(value, "value must be non-null") + .getBytes(StandardCharsets.US_ASCII); + } + + static final class ProgramInvocation implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final byte[] key; + private final List arguments; + + private ProgramInvocation(RedisProgramId programId, byte[] key, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.key = Objects.requireNonNull(key, "key must be non-null").clone(); + this.arguments = arguments.stream().map(byte[]::clone).toList(); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return RedisCatalogProgramInvocation.ReplyShape.MULTI; + } + + @Override + public List copyKeys() { + return List.of(key.clone()); + } + + @Override + public List copyArguments() { + return arguments.stream().map(byte[]::clone).toList(); + } + } + + @Override + public void close() { + lifecycle.close(keys::close); + } + + boolean destroyed() { + return lifecycle.closed() && keys.destroyed(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyTokenGenerator.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyTokenGenerator.java new file mode 100644 index 0000000..b61d811 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyTokenGenerator.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; + +/** Generates caller-retained owner tokens without embedding request scope data. */ +final class RedisIdempotencyTokenGenerator { + + private final SecureRandom random; + + RedisIdempotencyTokenGenerator(SecureRandom random) { + this.random = Objects.requireNonNull(random, "random must be non-null"); + } + + static RedisIdempotencyTokenGenerator secure() { + return new RedisIdempotencyTokenGenerator(new SecureRandom()); + } + + String next() { + byte[] entropy = new byte[24]; + random.nextBytes(entropy); + return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisInvalidationTransport.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisInvalidationTransport.java new file mode 100644 index 0000000..49205d2 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisInvalidationTransport.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** + * Adapter-private bounded invalidation transport used by canonical CACHE-role composition. + * + *

Delivery is intentionally at-most-once. Consumers must treat disconnect as a signal to evict + * or bypass L1 until their own recovery policy declares the subscription healthy again. + */ +interface RedisInvalidationTransport { + + long publish(byte[] channel, byte[] message); + + Subscription subscribe(byte[] channel, Listener listener); + + interface Listener { + + void onMessage(byte[] wireMessage); + + void onDisconnected(); + } + + interface Subscription extends AutoCloseable { + + @Override + void close(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseKeyFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseKeyFactory.java new file mode 100644 index 0000000..0082b2d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseKeyFactory.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Produces one Cluster-safe HMAC-pseudonymous key for an efficiency lease resource. */ +final class RedisLeaseKeyFactory implements AutoCloseable { + + private final String application; + private final String environment; + private final int hashKeyVersion; + private final int keyVersion; + private final byte[] hmacSecret; + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisLeaseKeyFactory( + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret) { + this.application = Objects.requireNonNull(application, "application must be non-null"); + this.environment = Objects.requireNonNull(environment, "environment must be non-null"); + this.hashKeyVersion = hashKeyVersion; + this.keyVersion = keyVersion; + this.hmacSecret = + Arrays.copyOf( + Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length); + if (this.hmacSecret.length < 32) { + throw new IllegalArgumentException("lease key HMAC secret requires at least 32 bytes"); + } + } + + byte[] physicalKey(String purpose, String resourceDigest) { + if (closed.get()) { + throw new IllegalStateException("lease key material is closed"); + } + RedisKeyNamespace namespace = + new RedisKeyNamespace( + application, + environment, + "lease", + Objects.requireNonNull(purpose, "purpose must be non-null"), + hashKeyVersion, + keyVersion, + "owner", + 512); + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + hashKeyVersion, hmacSecret, List.of(utf8(purpose), utf8(resourceDigest))); + return utf8(RedisKeyBuilder.build(namespace, digest)); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + boolean destroyed() { + if (!closed.get()) { + return false; + } + for (byte value : hmacSecret) { + if (value != 0) { + return false; + } + } + return true; + } + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseLifecycle.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseLifecycle.java new file mode 100644 index 0000000..5bb03ec --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseLifecycle.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Supplier; + +/** Prevents lease commands from racing provider shutdown and secret destruction. */ +final class RedisLeaseLifecycle { + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private boolean closed; + + T withOpen(Supplier operation) { + lock.readLock().lock(); + try { + if (closed) { + throw new IllegalStateException("Redis efficiency-lease provider is closed"); + } + return operation.get(); + } finally { + lock.readLock().unlock(); + } + } + + void close(Runnable destroy) { + lock.writeLock().lock(); + try { + if (!closed) { + destroy.run(); + closed = true; + } + } finally { + lock.writeLock().unlock(); + } + } + + boolean closed() { + lock.readLock().lock(); + try { + return closed; + } finally { + lock.readLock().unlock(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramExecutor.java new file mode 100644 index 0000000..c909cd8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramExecutor.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +/** Executes and fail-closed parses the efficiency-lease six-field protocol. */ +final class RedisLeaseProgramExecutor { + + private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L; + private final RedisProgramCatalog catalog; + private final RedisStructuredCommands commands; + + RedisLeaseProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + } + + RedisLeaseProgramReply execute(RedisEfficiencyLeaseProvider.ProgramInvocation material) { + return executeOwned(material); + } + + RedisLeaseProgramReply execute(RedisEfficiencyLeaseHandle.ProgramInvocation material) { + return executeOwned(material); + } + + private RedisLeaseProgramReply executeOwned(RedisCatalogProgramMaterial material) { + RedisProgramId id = material.programId(); + RedisProgramDescriptor descriptor = catalog.descriptor(id); + RedisCatalogProgramInvocation invocation = catalog.capabilityInvocation(material); + List result = RedisScriptRecovery.evalMulti(commands, invocation); + if (result == null || result.size() != descriptor.replyFieldCount()) { + throw incompatible(id); + } + for (byte[] field : result) { + if (field == null || field.length > descriptor.maximumReplyFieldBytes()) { + throw incompatible(id); + } + } + String status = ascii(result.get(0), id); + if (!descriptor.statuses().contains(status)) { + throw new RedisProgramCompatibilityException(id, status); + } + return new RedisLeaseProgramReply( + status, + unsigned(result.get(1), id), + unsigned(result.get(2), id), + unsigned(result.get(3), id), + unsigned(result.get(4), id), + ascii(result.get(5), id)); + } + + private static String ascii(byte[] value, RedisProgramId id) { + for (byte character : value) { + if (character < 0x20 || character > 0x7e) { + throw incompatible(id); + } + } + return new String(value, StandardCharsets.US_ASCII); + } + + private static long unsigned(byte[] value, RedisProgramId id) { + String encoded = ascii(value, id); + if (!encoded.matches("0|[1-9][0-9]{0,15}")) { + throw incompatible(id); + } + try { + long parsed = Long.parseLong(encoded); + if (parsed > MAXIMUM_EXACT_LUA_INTEGER) { + throw incompatible(id); + } + return parsed; + } catch (NumberFormatException failure) { + throw incompatible(id); + } + } + + private static RedisProgramCompatibilityException incompatible(RedisProgramId id) { + return new RedisProgramCompatibilityException(id, ""); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramReply.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramReply.java new file mode 100644 index 0000000..e329361 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramReply.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Fixed six-field efficiency-lease program reply. */ +record RedisLeaseProgramReply( + String status, + long remainingMillis, + long serverNowMillis, + long serverExpiryMillis, + long stateRevision, + String operationId) {} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettings.java new file mode 100644 index 0000000..9628e9c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettings.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** + * Canonical policy and key namespace for the Redis efficiency-lease capability. + * + *

Topology, credentials, ACL and TLS stay in the provider/deployment registry. This settings + * group only names the HMAC material and lease-specific local validity assumptions. + */ +@ConfigurationProperties(prefix = "ca-skeleton.capabilities.lease") +public record RedisLeaseSettings( + String provider, + String keyHmacSecretReference, + String namespaceApplication, + String namespaceEnvironment, + int hashKeyVersion, + int keyVersion, + Duration driftBudget) { + + private static final Duration MAXIMUM_DRIFT_BUDGET = Duration.ofSeconds(5); + + @ConstructorBinding + public RedisLeaseSettings { + provider = provider == null ? "" : provider.trim(); + keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim(); + namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); + namespaceEnvironment = defaultText(namespaceEnvironment, "local"); + hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion; + keyVersion = keyVersion == 0 ? 1 : keyVersion; + driftBudget = driftBudget == null ? Duration.ofMillis(10) : driftBudget; + if (driftBudget.isNegative() + || driftBudget.compareTo(MAXIMUM_DRIFT_BUDGET) > 0 + || !Duration.ofMillis(driftBudget.toMillis()).equals(driftBudget)) { + throw new IllegalArgumentException( + "lease driftBudget must be non-negative, at most 5 seconds, and use whole milliseconds"); + } + new RedisKeyNamespace( + namespaceApplication, + namespaceEnvironment, + "lease", + "validation", + hashKeyVersion, + keyVersion, + "owner", + 512); + } + + void validateActive() { + if (!"redis".equals(provider)) { + throw new IllegalArgumentException( + "lease provider must be redis when this adapter is active"); + } + RedisSecretReference.parse(keyHmacSecretReference); + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseTokenGenerator.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseTokenGenerator.java new file mode 100644 index 0000000..124488b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseTokenGenerator.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; + +/** Allocates caller-retained owner tokens without a Redis side effect. */ +final class RedisLeaseTokenGenerator { + + private final SecureRandom random; + + RedisLeaseTokenGenerator(SecureRandom random) { + this.random = Objects.requireNonNull(random, "random must be non-null"); + } + + String next() { + byte[] entropy = new byte[24]; + random.nextBytes(entropy); + return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitInterruptedException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitInterruptedException.java new file mode 100644 index 0000000..6b795ee --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitInterruptedException.java @@ -0,0 +1,7 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Internal signal preserving interruption while a bounded lease wait is cancelled. */ +final class RedisLeaseWaitInterruptedException extends RuntimeException { + + private static final long serialVersionUID = 1L; +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitStrategy.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitStrategy.java new file mode 100644 index 0000000..3ce0c77 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseWaitStrategy.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.concurrent.locks.LockSupport; + +@FunctionalInterface +interface RedisLeaseWaitStrategy { + + void await(Duration duration); + + static RedisLeaseWaitStrategy parking() { + return duration -> { + LockSupport.parkNanos(duration.toNanos()); + if (Thread.interrupted()) { + Thread.currentThread().interrupt(); + throw new RedisLeaseWaitInterruptedException(); + } + }; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLegacyStandaloneSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLegacyStandaloneSettings.java new file mode 100644 index 0000000..a7982ff --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLegacyStandaloneSettings.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; + +/** + * Explicit migration-only settings for the retired standalone rate-limit runtime. + * + *

This type is not a configuration-properties target and cannot become the production primary. + */ +record RedisLegacyStandaloneSettings( + String host, + int port, + String password, + String legacyKeyHmacSecret, + Duration commandTimeout, + int maximumCommandBytes, + int maximumQueuedCommands, + int maximumInFlightBytes, + String namespaceApplication, + String namespaceEnvironment) { + + private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30); + + RedisLegacyStandaloneSettings { + host = host == null ? "" : host.trim(); + port = port == 0 ? 6379 : port; + password = password == null ? "" : password; + legacyKeyHmacSecret = legacyKeyHmacSecret == null ? "" : legacyKeyHmacSecret; + commandTimeout = commandTimeout == null ? Duration.ofSeconds(1) : commandTimeout; + maximumCommandBytes = maximumCommandBytes == 0 ? 16_384 : maximumCommandBytes; + maximumQueuedCommands = maximumQueuedCommands == 0 ? 32 : maximumQueuedCommands; + maximumInFlightBytes = maximumInFlightBytes == 0 ? 1_048_576 : maximumInFlightBytes; + namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); + namespaceEnvironment = defaultText(namespaceEnvironment, "local"); + + if (host.length() > 253 + || host.chars().anyMatch(Character::isWhitespace) + || host.contains("/") + || host.contains("\\")) { + throw new IllegalArgumentException("legacy rate-limit Redis host is invalid"); + } + if (port < 1 || port > 65_535) { + throw new IllegalArgumentException("legacy rate-limit Redis port must be in 1..65535"); + } + Objects.requireNonNull(commandTimeout, "commandTimeout must be non-null"); + if (commandTimeout.isZero() + || commandTimeout.isNegative() + || commandTimeout.compareTo(MAXIMUM_TIMEOUT) > 0) { + throw new IllegalArgumentException("legacy rate-limit Redis command timeout is invalid"); + } + if (maximumCommandBytes < 16_384 || maximumCommandBytes > 65_536) { + throw new IllegalArgumentException("legacy rate-limit Redis command bytes are invalid"); + } + if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) { + throw new IllegalArgumentException("legacy rate-limit Redis queue bound is invalid"); + } + if (maximumInFlightBytes < maximumCommandBytes || maximumInFlightBytes > 268_435_456) { + throw new IllegalArgumentException("legacy rate-limit Redis byte budget is invalid"); + } + slug(namespaceApplication, "legacy rate-limit namespace application"); + slug(namespaceEnvironment, "legacy rate-limit namespace environment"); + } + + byte[] hmacSecret() { + try { + byte[] decoded = Base64.getDecoder().decode(legacyKeyHmacSecret); + if (decoded.length < 32) { + throw new IllegalArgumentException("legacy HMAC material is too short"); + } + return decoded; + } catch (IllegalArgumentException ignored) { + throw new IllegalArgumentException("legacy HMAC material is invalid"); + } + } + + private static void slug(String value, String field) { + if (!value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException(field + " has invalid format"); + } + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceClientOptionsFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceClientOptionsFactory.java new file mode 100644 index 0000000..35c1ed9 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceClientOptionsFactory.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import io.lettuce.core.SslOptions; +import io.lettuce.core.TimeoutOptions; +import io.lettuce.core.cluster.ClusterClientOptions; +import io.lettuce.core.cluster.ClusterTopologyRefreshOptions; + +/** Builds bounded no-replay Lettuce options for standalone/Sentinel and Cluster clients. */ +final class RedisLettuceClientOptionsFactory { + + ClientOptions clientOptions(RedisClientRuntimeSettings settings) { + return clientOptions(settings, null); + } + + public ClientOptions clientOptions( + RedisClientRuntimeSettings settings, SslOptions explicitSslOptions) { + SocketOptions socketOptions = + SocketOptions.builder().connectTimeout(settings.connectTimeout()).build(); + ClientOptions.Builder builder = + ClientOptions.builder() + .autoReconnect(true) + .replayFilter(ignored -> true) + .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) + .requestQueueSize(settings.maximumQueuedCommands()) + .socketOptions(socketOptions) + .timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout())); + if (explicitSslOptions != null) { + builder.sslOptions(explicitSslOptions); + } + return builder.build(); + } + + ClusterClientOptions clusterClientOptions(RedisClientRuntimeSettings settings) { + return clusterClientOptions(settings, null); + } + + public ClusterClientOptions clusterClientOptions( + RedisClientRuntimeSettings settings, SslOptions explicitSslOptions) { + SocketOptions socketOptions = + SocketOptions.builder().connectTimeout(settings.connectTimeout()).build(); + ClusterTopologyRefreshOptions topologyRefresh = + ClusterTopologyRefreshOptions.builder() + .enablePeriodicRefresh(settings.clusterTopologyRefreshPeriod()) + .enableAllAdaptiveRefreshTriggers() + .adaptiveRefreshTriggersTimeout(settings.commandTimeout()) + .closeStaleConnections(true) + .dynamicRefreshSources(true) + .build(); + + ClusterClientOptions.Builder builder = ClusterClientOptions.builder(); + builder.autoReconnect(true); + builder.replayFilter(ignored -> true); + builder.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS); + builder.requestQueueSize(settings.maximumQueuedCommands()); + builder.socketOptions(socketOptions); + builder.timeoutOptions(TimeoutOptions.enabled(settings.commandTimeout())); + if (explicitSslOptions != null) { + builder.sslOptions(explicitSslOptions); + } + builder.maxRedirects(settings.clusterMaximumRedirects()); + builder.topologyRefreshOptions(topologyRefresh); + builder.validateClusterNodeMembership(true); + return builder.build(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUriFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUriFactory.java new file mode 100644 index 0000000..6db3014 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUriFactory.java @@ -0,0 +1,213 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisCredentialsProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import io.lettuce.core.RedisURI; +import io.lettuce.core.SslVerifyMode; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** Converts validated topology settings into deterministic credential-bearing Lettuce URIs. */ +final class RedisLettuceUriFactory { + + private final RedisCredentialMaterialProvider materialProvider; + private final Clock clock; + + RedisLettuceUriFactory(RedisCredentialMaterialProvider materialProvider, Clock clock) { + this.materialProvider = + Objects.requireNonNull(materialProvider, "materialProvider must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + RedisLettuceUris create( + RedisDeploymentSettings deployment, RedisClientRuntimeSettings clientSettings) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(clientSettings, "clientSettings must be non-null"); + return switch (deployment) { + case RedisDeploymentSettings.Standalone standalone -> standalone(standalone, clientSettings); + case RedisDeploymentSettings.Sentinel sentinel -> sentinelDiscovery(sentinel, clientSettings); + case RedisDeploymentSettings.Cluster cluster -> cluster(cluster, clientSettings); + }; + } + + /** + * Applies an operation to newly created credential-owning URIs. + * + *

A successful operation assumes ownership of the supplied URI set. If the operation fails, + * this factory destroys every credential before propagating the failure. + */ + T mapOwnedUris( + RedisDeploymentSettings deployment, + RedisClientRuntimeSettings clientSettings, + Function operation) { + Objects.requireNonNull(operation, "operation must be non-null"); + RedisLettuceUris uris = create(deployment, clientSettings); + try { + return operation.apply(uris); + } catch (RuntimeException exception) { + uris.close(); + throw exception; + } + } + + RedisLettuceUris.SentinelDiscovery createSentinelDiscovery( + RedisDeploymentSettings.Sentinel deployment, RedisClientRuntimeSettings clientSettings) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(clientSettings, "clientSettings must be non-null"); + return sentinelDiscovery(deployment, clientSettings); + } + + RedisLettuceUris.SentinelData createSentinelData( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelMasterDiscovery.DataEndpoint endpoint, + RedisClientRuntimeSettings clientSettings) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(endpoint, "approved endpoint must be non-null"); + Objects.requireNonNull(clientSettings, "clientSettings must be non-null"); + validateFullTls(deployment.dataTls(), "Redis Sentinel data-node TLS"); + return withPassword( + deployment.dataAuthentication(), + credentials -> + new RedisLettuceUris.SentinelData( + dataUri( + new RedisDeploymentSettings.Endpoint(endpoint.host(), endpoint.port()), + deployment.database(), + credentials, + deployment.dataTls(), + clientSettings))); + } + + T mapOwnedSentinelData( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelMasterDiscovery.DataEndpoint endpoint, + RedisClientRuntimeSettings clientSettings, + Function operation) { + Objects.requireNonNull(operation, "operation must be non-null"); + RedisLettuceUris.SentinelData uris = createSentinelData(deployment, endpoint, clientSettings); + try { + return operation.apply(uris); + } catch (RuntimeException exception) { + uris.close(); + throw exception; + } + } + + private RedisLettuceUris.Standalone standalone( + RedisDeploymentSettings.Standalone deployment, RedisClientRuntimeSettings clientSettings) { + if (deployment.endpoints().size() != 1) { + throw new IllegalArgumentException( + "Redis standalone deployment must contain exactly one data endpoint"); + } + RedisDeploymentSettings.Endpoint endpoint = deployment.endpoints().getFirst(); + RedisURI dataUri = + withPassword( + deployment.dataAuthentication(), + credentials -> + dataUri( + endpoint, + deployment.database(), + credentials, + deployment.dataTls(), + clientSettings)); + return new RedisLettuceUris.Standalone(dataUri); + } + + private RedisLettuceUris.SentinelDiscovery sentinelDiscovery( + RedisDeploymentSettings.Sentinel deployment, RedisClientRuntimeSettings clientSettings) { + validateFullTls(deployment.sentinelTls(), "Redis Sentinel discovery TLS"); + return withPassword( + deployment.sentinelAuthentication(), + sentinelCredentials -> { + List discoveryUris = new ArrayList<>(); + for (RedisDeploymentSettings.Endpoint endpoint : deployment.sentinelEndpoints()) { + discoveryUris.add( + dataUri( + endpoint, 0, sentinelCredentials, deployment.sentinelTls(), clientSettings)); + } + return new RedisLettuceUris.SentinelDiscovery(discoveryUris); + }); + } + + private RedisLettuceUris.Cluster cluster( + RedisDeploymentSettings.Cluster deployment, RedisClientRuntimeSettings clientSettings) { + if (deployment.database() != 0) { + throw new IllegalArgumentException("Redis Cluster data URI must use database 0"); + } + return withPassword( + deployment.dataAuthentication(), + credentials -> { + List seedUris = + deployment.seedEndpoints().stream() + .map( + endpoint -> + dataUri(endpoint, 0, credentials, deployment.dataTls(), clientSettings)) + .toList(); + return new RedisLettuceUris.Cluster(seedUris); + }); + } + + private RedisURI dataUri( + RedisDeploymentSettings.Endpoint endpoint, + int database, + DestroyableRedisCredentialsProvider credentials, + RedisDeploymentSettings.Tls tls, + RedisClientRuntimeSettings clientSettings) { + validateFullTls(tls, "Redis data URI TLS"); + return RedisURI.Builder.redis(endpoint.host(), endpoint.port()) + .withAuthentication(credentials) + .withDatabase(database) + .withClientName(clientSettings.clientName()) + .withTimeout(clientSettings.commandTimeout()) + .withSsl(true) + .withVerifyPeer(SslVerifyMode.FULL) + .build(); + } + + private void validateFullTls(RedisDeploymentSettings.Tls tls, String field) { + Objects.requireNonNull(tls, field + " must be non-null"); + if (!tls.enabled() || !tls.verifyHostname()) { + throw new IllegalArgumentException(field + " must use TLS with FULL hostname verification"); + } + RedisSecretReference.parse(tls.trustBundleReference()); + } + + private T withPassword( + RedisDeploymentSettings.Authentication authentication, + Function operation) { + RedisSecretReference reference = RedisSecretReference.parse(authentication.passwordReference()); + try (VersionedRedisCredentialMaterial material = resolve(reference)) { + if (material == null) { + throw new IllegalStateException("Redis credential material resolution returned no value"); + } + if (material.isExpiredAt(clock.instant())) { + throw new IllegalStateException("Redis credential material is expired"); + } + return material.useSecret( + password -> { + DestroyableRedisCredentialsProvider credentials = + DestroyableRedisCredentialsProvider.from(authentication.username(), password); + try { + return operation.apply(credentials); + } catch (RuntimeException exception) { + credentials.destroy(); + throw exception; + } + }); + } + } + + private VersionedRedisCredentialMaterial resolve(RedisSecretReference reference) { + try { + return materialProvider.resolve(reference); + } catch (RuntimeException ignored) { + throw new IllegalStateException("Redis credential material resolution failed"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUris.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUris.java new file mode 100644 index 0000000..6fdeb09 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUris.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import io.lettuce.core.RedisURI; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.security.auth.Destroyable; + +/** Topology-specific, credential-bearing Lettuce URI configuration. */ +sealed interface RedisLettuceUris extends AutoCloseable + permits RedisLettuceUris.Standalone, + RedisLettuceUris.SentinelDiscovery, + RedisLettuceUris.SentinelData, + RedisLettuceUris.Cluster { + + @Override + void close(); + + final class Standalone implements RedisLettuceUris { + + private final RedisURI dataUri; + private final AtomicBoolean closed = new AtomicBoolean(); + + Standalone(RedisURI dataUri) { + this.dataUri = Objects.requireNonNull(dataUri, "dataUri must be non-null"); + } + + RedisURI dataUri() { + return dataUri; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + destroyCredentials(List.of(dataUri)); + } + } + } + + final class SentinelDiscovery implements RedisLettuceUris { + + private final List discoveryUris; + private final AtomicBoolean closed = new AtomicBoolean(); + + SentinelDiscovery(List discoveryUris) { + this.discoveryUris = List.copyOf(discoveryUris); + } + + List discoveryUris() { + return discoveryUris; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + destroyCredentials(discoveryUris); + } + } + } + + final class SentinelData implements RedisLettuceUris { + + private final RedisURI dataUri; + private final AtomicBoolean closed = new AtomicBoolean(); + + SentinelData(RedisURI dataUri) { + this.dataUri = Objects.requireNonNull(dataUri, "dataUri must be non-null"); + } + + RedisURI dataUri() { + return dataUri; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + destroyCredentials(List.of(dataUri)); + } + } + } + + final class Cluster implements RedisLettuceUris { + + private final List seedUris; + private final AtomicBoolean closed = new AtomicBoolean(); + + Cluster(List seedUris) { + this.seedUris = List.copyOf(seedUris); + } + + List seedUris() { + return seedUris; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + destroyCredentials(seedUris); + } + } + } + + static void destroyCredentials(List uris) { + var destroyed = Collections.newSetFromMap(new IdentityHashMap()); + for (RedisURI uri : uris) { + if (uri.getCredentialsProvider() instanceof Destroyable destroyable + && destroyed.add(destroyable)) { + try { + destroyable.destroy(); + } catch (javax.security.auth.DestroyFailedException ignored) { + // The adapter-owned providers do not throw; remain fail-safe for alternate + // implementations. + } + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisListPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisListPrimitives.java new file mode 100644 index 0000000..5d38624 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisListPrimitives.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Non-blocking bounded list helpers; this is not a durable messaging abstraction. */ +final class RedisListPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + private final RedisPrimitiveDescriptor admission; + + RedisListPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + this.admission = catalog.descriptor(RedisPrimitiveId.LIST_ADMIT); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.LIST_ADMIT).key(slot, identity); + } + + RedisPrimitiveValue value(String value) { + return RedisPrimitiveValue.utf8(value, admission.maximumValueBytes()); + } + + RedisPrimitiveMutationResult admit( + RedisPrimitiveKey key, RedisPrimitiveValue value, Duration initialTimeToLive) { + return executor.mutate( + RedisPrimitiveId.LIST_ADMIT, + List.of(key), + new RedisPrimitiveInvocation.CapacityArguments( + value, + RedisPrimitiveLimit.of(admission.maximumElements(), admission), + initialTimeToLive)); + } + + RedisPrimitiveReply pop(RedisPrimitiveKey key) { + return executor.execute( + RedisPrimitiveId.LIST_POP, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE); + } + + RedisPrimitiveMutationResult trimNewest(RedisPrimitiveKey key, int retainCount) { + RedisPrimitiveDescriptor descriptor = + catalog.descriptor(RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS); + RedisPrimitiveLimit retain = RedisPrimitiveLimit.of(retainCount, descriptor); + return executor.mutate( + RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS, + List.of(key), + new RedisPrimitiveInvocation.AtomicArguments( + List.of( + RedisPrimitiveValue.utf8(Integer.toString(retain.value()), 128), + RedisPrimitiveValue.utf8(Integer.toString(descriptor.maximumElements()), 128)))); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicy.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicy.java new file mode 100644 index 0000000..dd33b49 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicy.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Objects; + +/** + * Finite accounting, expiry, reconciliation and subscriber bounds for the optional cache-only L1. + * + *

The weight budget is a conservative admission/eviction accounting proxy, not a JVM heap + * reservation or proof of an exact object-layout byte count. + */ +record RedisLocalCachePolicy( + int maximumEntries, + long maximumWeightBytes, + long maximumEntryWeightBytes, + Duration localTimeToLive, + Duration generationRecheckInterval, + int invalidationQueueCapacity) { + + private static final int MAXIMUM_ENTRIES = 1_000_000; + private static final long MAXIMUM_WEIGHT_BYTES = 1_073_741_824L; + private static final Duration MAXIMUM_LOCAL_TTL = Duration.ofHours(1); + private static final int MAXIMUM_QUEUE_CAPACITY = 65_536; + + RedisLocalCachePolicy { + if (maximumEntries < 1 || maximumEntries > MAXIMUM_ENTRIES) { + throw new IllegalArgumentException("maximumEntries must be in 1..1000000"); + } + if (maximumWeightBytes < 1 || maximumWeightBytes > MAXIMUM_WEIGHT_BYTES) { + throw new IllegalArgumentException("maximumWeightBytes must be in 1..1073741824"); + } + if (maximumEntryWeightBytes < 1 || maximumEntryWeightBytes > maximumWeightBytes) { + throw new IllegalArgumentException( + "maximumEntryWeightBytes must be positive and not exceed maximumWeightBytes"); + } + localTimeToLive = positive(localTimeToLive, MAXIMUM_LOCAL_TTL, "localTimeToLive"); + generationRecheckInterval = + positive(generationRecheckInterval, localTimeToLive, "generationRecheckInterval"); + if (invalidationQueueCapacity < 1 || invalidationQueueCapacity > MAXIMUM_QUEUE_CAPACITY) { + throw new IllegalArgumentException("invalidationQueueCapacity must be in 1..65536"); + } + } + + private static Duration positive(Duration value, Duration maximum, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegion.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegion.java new file mode 100644 index 0000000..a1714cc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegion.java @@ -0,0 +1,526 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheObservationEvent; +import dev.caskeleton.application.cache.CacheObservationPort; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.cache.CacheRegionPort; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Optional bounded L1 decorator for semantic cache values only. + * + *

Pub/Sub messages are best-effort eviction hints. A local entry never outlives either its own + * TTL or the L2 envelope hard expiry, and the region generation is periodically reconciled. + * Disconnect/queue overflow flushes every local entry and requires a successful generation read + * before L1 can admit data again. + */ +final class RedisLocalCacheRegion implements CacheRegionPort, AutoCloseable { + + private static final long ENTRY_OVERHEAD_BYTES = 128; + + private final String cacheName; + private final RedisCacheL2Region l2; + private final RedisLocalCachePolicy policy; + private final Clock clock; + private final CacheObservationPort observations; + private final String invalidationChannel; + private final RedisCacheInvalidationMessage.Codec messageCodec; + private final Consumer publisher; + private final RedisCacheInvalidationSubscriber invalidationSubscriber; + private final LinkedHashMap entries = new LinkedHashMap<>(16, 0.75f, true); + + private long localWeightBytes; + private String observedGeneration; + private Instant nextGenerationRecheck = Instant.MIN; + private boolean forceGenerationRecheck = true; + private boolean generationProbeInProgress; + private long invalidationEpoch; + + RedisLocalCacheRegion( + String cacheName, + RedisCacheL2Region l2, + RedisLocalCachePolicy policy, + Clock clock, + CacheObservationPort observations, + String invalidationChannel, + RedisCacheInvalidationMessage.Codec messageCodec, + Consumer publisher) { + this.cacheName = boundedCacheName(cacheName); + this.l2 = Objects.requireNonNull(l2, "l2 must be non-null"); + this.policy = Objects.requireNonNull(policy, "policy must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.observations = Objects.requireNonNull(observations, "observations must be non-null"); + this.invalidationChannel = + Objects.requireNonNull(invalidationChannel, "invalidationChannel must be non-null"); + this.messageCodec = Objects.requireNonNull(messageCodec, "messageCodec must be non-null"); + this.publisher = Objects.requireNonNull(publisher, "publisher must be non-null"); + this.invalidationSubscriber = + new RedisCacheInvalidationSubscriber( + policy.invalidationQueueCapacity(), + new RedisCacheInvalidationSubscriber.Target() { + @Override + public void apply(RedisCacheInvalidationMessage message) { + applyInvalidationHint(message); + } + + @Override + public void disconnected() { + subscriberDisconnected(); + } + + @Override + public void overflow() { + subscriberOverflow(); + } + + @Override + public void malformedMessage() { + observeMaintenance( + CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT, + CacheObservationEvent.MaintenanceResult.DROPPED, + CacheObservationEvent.MaintenanceCause.MALFORMED_MESSAGE, + 0); + } + }); + } + + RedisCacheInvalidationSubscriber invalidationSubscriber() { + return invalidationSubscriber; + } + + String invalidationChannel() { + return invalidationChannel; + } + + RedisCacheInvalidationMessage.Codec invalidationMessageCodec() { + return messageCodec; + } + + @Override + public CacheLookup lookup(String key) { + invalidationSubscriber.drain(); + Instant now = clock.instant(); + long localTierPermit = reconcileGenerationIfRequired(now); + String identity = l2.localEntryIdentity(key); + if (localTierPermit >= 0) { + CacheLookup.Hit local = localHit(identity, now, localTierPermit); + if (local != null) { + return local; + } + } else { + observeLookup( + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.BYPASS, + Duration.ZERO); + } + + CacheLookup lookup = l2.lookup(key); + observeLookup(CacheObservationEvent.Tier.REDIS_L2, lookupResult(lookup), Duration.ZERO); + if (localTierPermit >= 0 && lookup instanceof CacheLookup.Hit hit) { + admit(identity, hit, now, localTierPermit); + } + return lookup; + } + + @Override + public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) { + CacheRecordOutcome outcome = l2.record(key, value, metadata); + if (outcome == CacheRecordOutcome.RECORDED) { + invalidateLocalIdentity( + l2.localEntryIdentity(key), CacheObservationEvent.MaintenanceCause.INVALIDATION); + publish(RedisCacheInvalidationMessage.key(l2.localEntryIdentity(key))); + } + return outcome; + } + + @Override + public CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + CacheRecordOutcome outcome = l2.recordAbsent(key, reason, metadata); + if (outcome == CacheRecordOutcome.RECORDED) { + invalidateLocalIdentity( + l2.localEntryIdentity(key), CacheObservationEvent.MaintenanceCause.INVALIDATION); + publish(RedisCacheInvalidationMessage.key(l2.localEntryIdentity(key))); + } + return outcome; + } + + @Override + public CacheInvalidationOutcome invalidate(String key) { + String identity = l2.localEntryIdentity(key); + CacheInvalidationOutcome outcome = l2.invalidate(key); + invalidateLocalIdentity(identity, CacheObservationEvent.MaintenanceCause.INVALIDATION); + if (outcome == CacheInvalidationOutcome.INVALIDATED + || outcome == CacheInvalidationOutcome.INDETERMINATE) { + publish(RedisCacheInvalidationMessage.key(identity)); + } + return outcome; + } + + @Override + public CacheInvalidationOutcome invalidateRegion() { + CacheInvalidationOutcome outcome = l2.invalidateRegion(); + invalidateAllLocal( + CacheObservationEvent.MaintenanceCause.INVALIDATION, + CacheObservationEvent.MaintenanceAction.FLUSH, + CacheObservationEvent.MaintenanceResult.FLUSHED); + if (outcome == CacheInvalidationOutcome.INVALIDATED + || outcome == CacheInvalidationOutcome.INDETERMINATE) { + try { + publish(RedisCacheInvalidationMessage.region(l2.currentRegionGeneration())); + } catch (RuntimeException ignored) { + observeMaintenance( + CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT, + CacheObservationEvent.MaintenanceResult.ERROR, + CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE, + 0); + } + } + return outcome; + } + + synchronized int localEntryCount() { + return entries.size(); + } + + synchronized long localWeightBytes() { + return localWeightBytes; + } + + @Override + public void close() { + invalidateAllLocal( + CacheObservationEvent.MaintenanceCause.INVALIDATION, + CacheObservationEvent.MaintenanceAction.FLUSH, + CacheObservationEvent.MaintenanceResult.FLUSHED); + messageCodec.close(); + } + + private synchronized CacheLookup.Hit localHit( + String identity, Instant now, long permitEpoch) { + if (!permitCurrent(permitEpoch)) { + return null; + } + LocalEntry entry = entries.get(identity); + if (entry == null) { + observeLookup( + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.MISS, + Duration.ZERO); + return null; + } + if (!now.isBefore(entry.localExpiresAt()) || !now.isBefore(entry.hit().hardExpiresAt())) { + remove(identity); + observeMaintenance( + CacheObservationEvent.MaintenanceAction.EVICT, + CacheObservationEvent.MaintenanceResult.SUCCESS, + CacheObservationEvent.MaintenanceCause.TTL, + 1); + observeLookup( + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.MISS, + Duration.ZERO); + return null; + } + CacheLookup.Hit hit = entry.hit(); + CacheLookup.Hit current = + new CacheLookup.Hit<>( + hit.value(), + now.isBefore(hit.softExpiresAt()) + ? CacheLookup.Freshness.FRESH + : CacheLookup.Freshness.STALE, + hit.sourceRevision(), + hit.softExpiresAt(), + hit.hardExpiresAt(), + hit.observationToken(), + hit.writeCondition()); + observeLookup( + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.HIT, + nonNegativeDuration(entry.admittedAt(), now)); + return current; + } + + private synchronized void admit( + String identity, CacheLookup.Hit hit, Instant observedAt, long permitEpoch) { + if (!permitCurrent(permitEpoch)) { + return; + } + if (!observedAt.isBefore(hit.hardExpiresAt())) { + return; + } + long weight = conservativeEntryWeightBytes(identity, hit.value()); + if (weight > policy.maximumEntryWeightBytes() || weight > policy.maximumWeightBytes()) { + observeMaintenance( + CacheObservationEvent.MaintenanceAction.EVICT, + CacheObservationEvent.MaintenanceResult.DROPPED, + CacheObservationEvent.MaintenanceCause.WEIGHT, + 0); + return; + } + LocalEntry old = entries.remove(identity); + if (old != null) { + localWeightBytes -= old.weightBytes(); + } + while (!entries.isEmpty() + && (entries.size() >= policy.maximumEntries() + || localWeightBytes + weight > policy.maximumWeightBytes())) { + boolean cardinality = entries.size() >= policy.maximumEntries(); + Iterator> iterator = entries.entrySet().iterator(); + Map.Entry eldest = iterator.next(); + localWeightBytes -= eldest.getValue().weightBytes(); + iterator.remove(); + observeMaintenance( + CacheObservationEvent.MaintenanceAction.EVICT, + CacheObservationEvent.MaintenanceResult.SUCCESS, + cardinality + ? CacheObservationEvent.MaintenanceCause.CARDINALITY + : CacheObservationEvent.MaintenanceCause.WEIGHT, + 1); + } + Instant localExpiresAt = + earlier(hit.hardExpiresAt(), plus(observedAt, policy.localTimeToLive())); + entries.put(identity, new LocalEntry(hit, observedAt, localExpiresAt, weight)); + localWeightBytes += weight; + } + + private long reconcileGenerationIfRequired(Instant now) { + long probeEpoch; + synchronized (this) { + if (!forceGenerationRecheck && now.isBefore(nextGenerationRecheck)) { + return invalidationEpoch; + } + if (generationProbeInProgress) { + return -1; + } + generationProbeInProgress = true; + probeEpoch = invalidationEpoch; + } + String current; + try { + current = l2.currentRegionGeneration(); + } catch (RuntimeException exception) { + synchronized (this) { + generationProbeInProgress = false; + invalidationEpoch++; + forceGenerationRecheck = true; + nextGenerationRecheck = now; + flushInsideLock( + CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE, + CacheObservationEvent.MaintenanceAction.RECONCILE, + CacheObservationEvent.MaintenanceResult.ERROR); + return -1; + } + } + synchronized (this) { + generationProbeInProgress = false; + if (probeEpoch != invalidationEpoch) { + forceGenerationRecheck = true; + nextGenerationRecheck = now; + return -1; + } + boolean changed = observedGeneration != null && !observedGeneration.equals(current); + if (changed) { + invalidationEpoch++; + flushInsideLock( + CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED, + CacheObservationEvent.MaintenanceAction.RECONCILE, + CacheObservationEvent.MaintenanceResult.FLUSHED); + } else { + observeMaintenance( + CacheObservationEvent.MaintenanceAction.RECONCILE, + CacheObservationEvent.MaintenanceResult.UNCHANGED, + CacheObservationEvent.MaintenanceCause.INVALIDATION, + 0); + } + observedGeneration = current; + forceGenerationRecheck = false; + nextGenerationRecheck = plus(now, policy.generationRecheckInterval()); + return invalidationEpoch; + } + } + + private void applyInvalidationHint(RedisCacheInvalidationMessage message) { + if (message instanceof RedisCacheInvalidationMessage.Key key) { + invalidateLocalIdentity(key.value(), CacheObservationEvent.MaintenanceCause.INVALIDATION); + return; + } + invalidateAllLocal( + CacheObservationEvent.MaintenanceCause.INVALIDATION, + CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT, + CacheObservationEvent.MaintenanceResult.FLUSHED); + } + + private void subscriberDisconnected() { + invalidateAllLocal( + CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED, + CacheObservationEvent.MaintenanceAction.FLUSH, + CacheObservationEvent.MaintenanceResult.FLUSHED); + } + + private void subscriberOverflow() { + invalidateAllLocal( + CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW, + CacheObservationEvent.MaintenanceAction.FLUSH, + CacheObservationEvent.MaintenanceResult.FLUSHED); + } + + private synchronized void invalidateLocalIdentity( + String identity, CacheObservationEvent.MaintenanceCause cause) { + invalidationEpoch++; + LocalEntry removed = remove(identity); + if (removed != null) { + observeMaintenance( + CacheObservationEvent.MaintenanceAction.EVICT, + CacheObservationEvent.MaintenanceResult.SUCCESS, + cause, + 1); + } + } + + private LocalEntry remove(String identity) { + LocalEntry removed = entries.remove(identity); + if (removed != null) { + localWeightBytes -= removed.weightBytes(); + } + return removed; + } + + private synchronized void invalidateAllLocal( + CacheObservationEvent.MaintenanceCause cause, + CacheObservationEvent.MaintenanceAction action, + CacheObservationEvent.MaintenanceResult result) { + invalidationEpoch++; + forceGenerationRecheck = true; + flushInsideLock(cause, action, result); + } + + private void flushInsideLock( + CacheObservationEvent.MaintenanceCause cause, + CacheObservationEvent.MaintenanceAction action, + CacheObservationEvent.MaintenanceResult result) { + int affected = entries.size(); + entries.clear(); + localWeightBytes = 0; + observeMaintenance(action, result, cause, affected); + } + + private boolean permitCurrent(long permitEpoch) { + return permitEpoch == invalidationEpoch && !forceGenerationRecheck; + } + + private void publish(RedisCacheInvalidationMessage message) { + try { + publisher.accept(messageCodec.encode(message)); + observeMaintenance( + CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT, + CacheObservationEvent.MaintenanceResult.SUCCESS, + CacheObservationEvent.MaintenanceCause.INVALIDATION, + 0); + } catch (RuntimeException ignored) { + observeMaintenance( + CacheObservationEvent.MaintenanceAction.SUBSCRIBER_EVENT, + CacheObservationEvent.MaintenanceResult.ERROR, + CacheObservationEvent.MaintenanceCause.RECONCILIATION_FAILURE, + 0); + } + } + + private void observeLookup( + CacheObservationEvent.Tier tier, + CacheObservationEvent.LookupResult result, + Duration entryAge) { + observe(new CacheObservationEvent.Lookup(cacheName, tier, result, entryAge)); + } + + private void observeMaintenance( + CacheObservationEvent.MaintenanceAction action, + CacheObservationEvent.MaintenanceResult result, + CacheObservationEvent.MaintenanceCause cause, + int affectedEntries) { + observe( + new CacheObservationEvent.LocalMaintenance( + cacheName, action, result, cause, affectedEntries)); + } + + private void observe(CacheObservationEvent event) { + try { + observations.observe(event); + } catch (RuntimeException ignored) { + // Metrics/logging must never change cache semantics. + } + } + + private static CacheObservationEvent.LookupResult lookupResult(CacheLookup lookup) { + if (lookup instanceof CacheLookup.Hit || lookup instanceof CacheLookup.NegativeHit) { + return CacheObservationEvent.LookupResult.HIT; + } + if (lookup instanceof CacheLookup.Miss) { + return CacheObservationEvent.LookupResult.MISS; + } + return CacheObservationEvent.LookupResult.ERROR; + } + + /** + * Accounts UTF-8 identity/value bytes plus a fixed conservative allowance for the entry, lookup + * metadata, timestamps and map-node references. It is not an exact JVM heap measurement. + */ + static long conservativeEntryWeightBytes(String identity, String value) { + return Math.addExact( + ENTRY_OVERHEAD_BYTES, + Math.addExact( + identity.getBytes(StandardCharsets.UTF_8).length, + value.getBytes(StandardCharsets.UTF_8).length)); + } + + private static Instant earlier(Instant first, Instant second) { + return first.isBefore(second) ? first : second; + } + + private static Instant plus(Instant value, Duration duration) { + try { + return value.plus(duration); + } catch (ArithmeticException | DateTimeException exception) { + throw new IllegalStateException( + "local cache expiry exceeds supported instant range", exception); + } + } + + private static Duration nonNegativeDuration(Instant from, Instant to) { + return to.isBefore(from) ? Duration.ZERO : Duration.between(from, to); + } + + private static String boundedCacheName(String value) { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException( + "cacheName must be a code-owned lower-case slug with 1..63 characters"); + } + return value; + } + + private record LocalEntry( + CacheLookup.Hit hit, Instant admittedAt, Instant localExpiresAt, long weightBytes) { + + private LocalEntry { + Objects.requireNonNull(hit, "hit must be non-null"); + Objects.requireNonNull(admittedAt, "admittedAt must be non-null"); + Objects.requireNonNull(localExpiresAt, "localExpiresAt must be non-null"); + if (weightBytes < 1) { + throw new IllegalArgumentException("weightBytes must be positive"); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettings.java new file mode 100644 index 0000000..a1d275b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettings.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Typed, default-off settings for the cache-only local L1 tier. */ +@ConfigurationProperties(prefix = "app.cache.redis.l1") +public record RedisLocalCacheSettings( + boolean enabled, + int maximumEntries, + long maximumWeightBytes, + long maximumEntryWeightBytes, + Duration timeToLive, + Duration generationRecheckInterval, + int invalidationQueueCapacity) { + + @ConstructorBinding + public RedisLocalCacheSettings { + maximumEntries = maximumEntries == 0 ? 10_000 : maximumEntries; + maximumWeightBytes = maximumWeightBytes == 0 ? 67_108_864 : maximumWeightBytes; + maximumEntryWeightBytes = maximumEntryWeightBytes == 0 ? 1_048_576 : maximumEntryWeightBytes; + timeToLive = timeToLive == null ? Duration.ofSeconds(30) : timeToLive; + generationRecheckInterval = + generationRecheckInterval == null ? Duration.ofSeconds(5) : generationRecheckInterval; + invalidationQueueCapacity = invalidationQueueCapacity == 0 ? 1024 : invalidationQueueCapacity; + new RedisLocalCachePolicy( + maximumEntries, + maximumWeightBytes, + maximumEntryWeightBytes, + timeToLive, + generationRecheckInterval, + invalidationQueueCapacity); + } + + RedisLocalCachePolicy policy() { + return new RedisLocalCachePolicy( + maximumEntries, + maximumWeightBytes, + maximumEntryWeightBytes, + timeToLive, + generationRecheckInterval, + invalidationQueueCapacity); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java index 453c617..4742e68 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutor.java @@ -1,17 +1,11 @@ package dev.caskeleton.adapter.outbound.cache.redis; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import java.util.List; import java.util.Objects; /** Executes an exact catalog script through EVALSHA, with EVAL allowed only after NOSCRIPT. */ final class RedisLuaProgramExecutor implements RedisProgramExecutor { - private static final HexFormat HEX = HexFormat.of(); - private final RedisProgramCatalog catalog; private final RedisBinaryCommands commands; @@ -21,19 +15,13 @@ final class RedisLuaProgramExecutor implements RedisProgramExecutor { } @Override - public String execute( - RedisProgramDescriptor descriptor, List keys, List arguments) { - Objects.requireNonNull(descriptor, "descriptor must be non-null"); + public String execute(RedisCatalogProgramInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + RedisProgramDescriptor descriptor = invocation.descriptor(); if (catalog.descriptor(descriptor.id()) != descriptor) { throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog"); } - validate(descriptor, keys, arguments); - byte[] result; - try { - result = commands.evalSha(sha1(descriptor.scriptBytes()), keys, arguments); - } catch (RedisNoScriptException noScript) { - result = commands.eval(descriptor.scriptBytes(), keys, arguments); - } + byte[] result = RedisScriptRecovery.evalValue(commands, invocation); if (result == null || result.length == 0 || result.length > 128) { throw new IllegalStateException("Redis program returned an invalid status payload"); } @@ -43,33 +31,4 @@ final class RedisLuaProgramExecutor implements RedisProgramExecutor { } return status; } - - private static void validate( - RedisProgramDescriptor descriptor, List keys, List arguments) { - Objects.requireNonNull(keys, "keys must be non-null"); - Objects.requireNonNull(arguments, "arguments must be non-null"); - if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { - throw new IllegalArgumentException("Redis program signature does not match descriptor"); - } - for (byte[] key : keys) { - bounded(key, descriptor.maximumKeyBytes(), "key"); - } - for (byte[] argument : arguments) { - bounded(argument, descriptor.maximumArgumentBytes(), "argument"); - } - } - - private static void bounded(byte[] value, int maximumBytes, String field) { - if (value == null || value.length < 1 || value.length > maximumBytes) { - throw new IllegalArgumentException("Redis program " + field + " is out of bounds"); - } - } - - private static String sha1(byte[] script) { - try { - return HEX.formatHex(MessageDigest.getInstance("SHA-1").digest(script)); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-1 unavailable for Redis script identity", exception); - } - } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStore.java new file mode 100644 index 0000000..6f995f0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStore.java @@ -0,0 +1,621 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; + +/** + * Executes the closed Redis session Lua set with pseudonymous keys and bounded fail-closed replies. + */ +final class RedisLuaVersionedSessionStore implements VersionedRedisSessionStore, AutoCloseable { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder BASE64 = Base64.getEncoder(); + private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder(); + private static final HexFormat HEX = HexFormat.of(); + + private final RedisStructuredCommands commands; + private final RedisProgramCatalog catalog; + private final RedisKeyNamespace liveNamespace; + private final RedisKeyNamespace tombstoneNamespace; + private final int hashKeyVersion; + private final byte[] hmacSecret; + private final AtomicBoolean closed = new AtomicBoolean(); + private final RedisCapabilityObserver observer; + + RedisLuaVersionedSessionStore( + RedisStructuredCommands commands, + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret) { + this( + commands, + application, + environment, + hashKeyVersion, + keyVersion, + hmacSecret, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisLuaVersionedSessionStore( + RedisStructuredCommands commands, + String application, + String environment, + int hashKeyVersion, + int keyVersion, + byte[] hmacSecret, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + this.catalog = RedisProgramCatalog.sessionV1(); + this.liveNamespace = + new RedisKeyNamespace( + application, + environment, + "session", + "repository", + hashKeyVersion, + keyVersion, + "live", + 512); + this.tombstoneNamespace = + new RedisKeyNamespace( + application, + environment, + "session", + "repository", + hashKeyVersion, + keyVersion, + "tombstone", + 512); + this.hashKeyVersion = hashKeyVersion; + this.hmacSecret = + Arrays.copyOf( + Objects.requireNonNull(hmacSecret, "hmacSecret must be non-null"), hmacSecret.length); + if (this.hmacSecret.length < 32) { + throw new IllegalArgumentException("session key HMAC secret requires at least 32 bytes"); + } + this.observer = new RedisCapabilityObserver(observations, ticker); + } + + @Override + public SessionMutationAttempt newMutationAttempt() { + ensureOpen(); + byte[] random = new byte[24]; + RANDOM.nextBytes(random); + return new SessionMutationAttempt( + Base64.getUrlEncoder().withoutPadding().encodeToString(random)); + } + + @Override + public SessionCreateOutcome create(SessionCreateCommand command) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.SESSION, + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.Operation.SESSION_CREATE, + () -> createOpen(command), + RedisLuaVersionedSessionStore::classifyCreate); + } + + private SessionCreateOutcome createOpen(SessionCreateCommand command) { + Objects.requireNonNull(command, "command must be non-null"); + try { + String status = + status( + execute( + RedisProgramId.SESSION_CREATE_V1, + keys(command.sessionId()), + List.of( + base64(command.payload()), + ascii(command.newRevision()), + ascii(command.absoluteExpiresAt()), + ascii(command.lastAccessedAt()), + ascii(command.idleTimeout().toMillis()), + ascii(command.attempt().operationId()), + ascii(digest(command.payload()))))); + return SessionCreateOutcome.valueOf(status); + } catch (RedisCommandFailureException failure) { + return mutationFailure( + failure, SessionCreateOutcome.INDETERMINATE, SessionCreateOutcome.UNAVAILABLE); + } + } + + @Override + public SessionInspectionOutcome inspect(SessionInspectionCommand command) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.SESSION, + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.Operation.SESSION_INSPECT, + () -> inspectOpen(command), + RedisLuaVersionedSessionStore::classifyInspection); + } + + private SessionInspectionOutcome inspectOpen(SessionInspectionCommand command) { + Objects.requireNonNull(command, "command must be non-null"); + try { + List reply = + execute( + RedisProgramId.SESSION_INSPECT_V1, + keys(command.sessionId()), + List.of(ascii(command.now()))); + String status = status(reply); + return switch (status) { + case "LIVE" -> live(reply); + case "TOMBSTONED" -> new SessionInspectionOutcome.Tombstoned(); + case "ABSENT" -> new SessionInspectionOutcome.Absent(); + case "ABSOLUTE_EXPIRED" -> new SessionInspectionOutcome.AbsoluteExpired(); + default -> throw incompatible(RedisProgramId.SESSION_INSPECT_V1, status); + }; + } catch (RedisCommandFailureException failure) { + return new SessionInspectionOutcome.Unavailable(); + } + } + + @Override + public SessionSaveOutcome saveIfLive(SessionSaveCommand command) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.SESSION, + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.Operation.SESSION_SAVE, + () -> saveIfLiveOpen(command), + RedisLuaVersionedSessionStore::classifySave); + } + + private SessionSaveOutcome saveIfLiveOpen(SessionSaveCommand command) { + Objects.requireNonNull(command, "command must be non-null"); + try { + String status = + status( + execute( + RedisProgramId.SESSION_SAVE_IF_LIVE_V1, + keys(command.sessionId()), + List.of( + base64(command.payload()), + ascii(command.expectedRevision()), + ascii(command.newRevision()), + ascii(command.absoluteExpiresAt()), + ascii(command.lastAccessedAt()), + ascii(command.idleTimeout().toMillis()), + ascii(command.attempt().operationId()), + ascii(digest(command.payload()))))); + return SessionSaveOutcome.valueOf(status); + } catch (RedisCommandFailureException failure) { + return mutationFailure( + failure, SessionSaveOutcome.INDETERMINATE, SessionSaveOutcome.UNAVAILABLE); + } + } + + @Override + public SessionTouchOutcome touchIfLive(SessionTouchCommand command) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.SESSION, + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.Operation.SESSION_TOUCH, + () -> touchIfLiveOpen(command), + RedisLuaVersionedSessionStore::classifyTouch); + } + + private SessionTouchOutcome touchIfLiveOpen(SessionTouchCommand command) { + Objects.requireNonNull(command, "command must be non-null"); + try { + String status = + status( + execute( + RedisProgramId.SESSION_TOUCH_IF_LIVE_V1, + keys(command.sessionId()), + List.of( + ascii(command.expectedRevision()), + ascii(command.now()), + ascii(command.absoluteExpiresAt()), + ascii(command.idleTimeout().toMillis()), + ascii(command.touchInterval().toMillis()), + ascii(command.attempt().operationId())))); + return SessionTouchOutcome.valueOf(status); + } catch (RedisCommandFailureException failure) { + return mutationFailure( + failure, SessionTouchOutcome.INDETERMINATE, SessionTouchOutcome.UNAVAILABLE); + } + } + + @Override + public SessionRevokeOutcome tombstoneAndDelete(SessionRevokeCommand command) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.SESSION, + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.Operation.SESSION_REVOKE, + () -> tombstoneAndDeleteOpen(command), + RedisLuaVersionedSessionStore::classifyRevoke); + } + + private SessionRevokeOutcome tombstoneAndDeleteOpen(SessionRevokeCommand command) { + Objects.requireNonNull(command, "command must be non-null"); + try { + String status = + status( + execute( + RedisProgramId.SESSION_TOMBSTONE_AND_DELETE_V1, + keys(command.sessionId()), + List.of( + ascii(command.expectedRevision()), + ascii(command.tombstoneTimeToLive().toMillis()), + ascii(command.attempt().operationId())))); + return SessionRevokeOutcome.valueOf(status); + } catch (RedisCommandFailureException failure) { + return mutationFailure( + failure, SessionRevokeOutcome.INDETERMINATE, SessionRevokeOutcome.UNAVAILABLE); + } + } + + @Override + public SessionRotateOutcome rotate(SessionRotateCommand command) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.SESSION, + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.Operation.SESSION_ROTATE, + () -> rotateOpen(command), + RedisLuaVersionedSessionStore::classifyRotate); + } + + private SessionRotateOutcome rotateOpen(SessionRotateCommand command) { + Objects.requireNonNull(command, "command must be non-null"); + try { + RedisKeyPair oldKeys = physicalKeys(command.oldSessionId()); + RedisKeyPair newKeys = physicalKeys(command.newSessionId()); + String status = + status( + execute( + RedisProgramId.SESSION_ROTATE_V1, + List.of(oldKeys.live(), oldKeys.tombstone(), newKeys.live(), newKeys.tombstone()), + List.of( + base64(command.payload()), + ascii(command.expectedRevision()), + ascii(command.newRevision()), + ascii(command.absoluteExpiresAt()), + ascii(command.lastAccessedAt()), + ascii(command.idleTimeout().toMillis()), + ascii(command.tombstoneTimeToLive().toMillis()), + ascii(command.attempt().operationId()), + ascii(digest(command.payload())), + ascii(newKeys.resourceDigest())))); + return SessionRotateOutcome.valueOf(status); + } catch (RedisCommandFailureException failure) { + return mutationFailure( + failure, SessionRotateOutcome.INDETERMINATE, SessionRotateOutcome.UNAVAILABLE); + } + } + + private List execute(RedisProgramId program, List keys, List arguments) { + ensureOpen(); + RedisProgramDescriptor descriptor = catalog.descriptor(program); + if (keys.size() != descriptor.keyCount() || arguments.size() != descriptor.argumentCount()) { + throw new IllegalArgumentException("Redis session invocation shape is invalid"); + } + validateFields(keys, descriptor.maximumKeyBytes(), "key", false); + validateFields(arguments, descriptor.maximumArgumentBytes(), "argument", false); + List reply = + RedisScriptRecovery.evalMulti( + commands, + catalog.capabilityInvocation(new ProgramInvocation(program, keys, arguments))); + if (reply == null || reply.size() != descriptor.replyFieldCount()) { + throw incompatible(program, ""); + } + validateFields(reply, descriptor.maximumReplyFieldBytes(), "reply", true); + String status = status(reply); + if (!descriptor.statuses().contains(status)) { + throw incompatible(program, status); + } + return copy(reply); + } + + private SessionInspectionOutcome.Live live(List reply) { + if (reply.size() != 5) { + throw incompatible(RedisProgramId.SESSION_INSPECT_V1, ""); + } + try { + byte[] payload = BASE64_DECODER.decode(asciiText(reply.get(1))); + return new SessionInspectionOutcome.Live( + payload, + positiveLong(reply.get(2)), + Instant.ofEpochMilli(positiveLong(reply.get(3))), + Instant.ofEpochMilli(positiveLong(reply.get(4)))); + } catch (IllegalArgumentException exception) { + throw incompatible(RedisProgramId.SESSION_INSPECT_V1, ""); + } + } + + private List keys(String sessionId) { + RedisKeyPair keys = physicalKeys(sessionId); + return List.of(keys.live(), keys.tombstone()); + } + + static final class ProgramInvocation implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final List keys; + private final List arguments; + + private ProgramInvocation(RedisProgramId programId, List keys, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.keys = copy(keys); + this.arguments = copy(arguments); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return RedisCatalogProgramInvocation.ReplyShape.MULTI; + } + + @Override + public List copyKeys() { + return copy(keys); + } + + @Override + public List copyArguments() { + return copy(arguments); + } + } + + private RedisKeyPair physicalKeys(String sessionId) { + ensureOpen(); + RedisKeyDigest keyDigest = + RedisKeyDigest.sensitive( + hashKeyVersion, hmacSecret, List.of(sessionId.getBytes(StandardCharsets.US_ASCII))); + return new RedisKeyPair( + ascii(RedisKeyBuilder.build(liveNamespace, keyDigest)), + ascii(RedisKeyBuilder.build(tombstoneNamespace, keyDigest)), + keyDigest.resourceDigest()); + } + + private static String status(List reply) { + String value = asciiText(reply.getFirst()); + if (!value.matches("[A-Z][A-Z_]{1,63}")) { + throw incompatible(null, ""); + } + return value; + } + + private static long positiveLong(byte[] value) { + String text = asciiText(value); + if (!text.matches("[1-9][0-9]{0,18}")) { + throw new IllegalArgumentException("expected positive decimal"); + } + return Long.parseLong(text); + } + + private static String asciiText(byte[] value) { + for (byte character : value) { + if (character < 0x20 || character > 0x7e) { + throw new IllegalArgumentException("expected printable ASCII"); + } + } + return new String(value, StandardCharsets.US_ASCII); + } + + private static void validateFields( + List fields, int maximumBytes, String label, boolean allowEmpty) { + for (byte[] field : fields) { + if (field == null || (!allowEmpty && field.length < 1) || field.length > maximumBytes) { + throw new IllegalArgumentException("Redis session " + label + " field is out of bounds"); + } + } + } + + private static T mutationFailure( + RedisCommandFailureException failure, T indeterminate, T unavailable) { + return failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? indeterminate + : unavailable; + } + + private static RedisCapabilityObserver.Classification classifyCreate( + SessionCreateOutcome outcome) { + return classifyMutation( + outcome == SessionCreateOutcome.CREATED + || outcome == SessionCreateOutcome.ALREADY_CREATED_SAME_OPERATION, + outcome == SessionCreateOutcome.INDETERMINATE, + outcome == SessionCreateOutcome.UNAVAILABLE, + outcome == SessionCreateOutcome.EXISTS_CONFLICT + || outcome == SessionCreateOutcome.TOMBSTONED); + } + + static RedisCapabilityObserver.Classification classifyInspection( + SessionInspectionOutcome outcome) { + return switch (outcome) { + case SessionInspectionOutcome.Live ignored -> + definite(RedisCapabilityObservationEvent.Outcome.HIT); + case SessionInspectionOutcome.Absent ignored -> + definite(RedisCapabilityObservationEvent.Outcome.MISS); + case SessionInspectionOutcome.Tombstoned ignored -> + definite(RedisCapabilityObservationEvent.Outcome.TOMBSTONED); + case SessionInspectionOutcome.AbsoluteExpired ignored -> + definite(RedisCapabilityObservationEvent.Outcome.ABSOLUTE_EXPIRED); + case SessionInspectionOutcome.Unavailable ignored -> unavailable(); + }; + } + + private static RedisCapabilityObserver.Classification classifySave(SessionSaveOutcome outcome) { + return classifyMutation( + outcome == SessionSaveOutcome.SAVED + || outcome == SessionSaveOutcome.ALREADY_SAVED_SAME_OPERATION, + outcome == SessionSaveOutcome.INDETERMINATE, + outcome == SessionSaveOutcome.UNAVAILABLE, + outcome == SessionSaveOutcome.STALE_REVISION + || outcome == SessionSaveOutcome.MUTATION_CONFLICT + || outcome == SessionSaveOutcome.TOMBSTONED); + } + + private static RedisCapabilityObserver.Classification classifyTouch(SessionTouchOutcome outcome) { + return classifyMutation( + outcome == SessionTouchOutcome.TOUCHED + || outcome == SessionTouchOutcome.ALREADY_TOUCHED_SAME_OPERATION + || outcome == SessionTouchOutcome.TOUCH_NOT_DUE, + outcome == SessionTouchOutcome.INDETERMINATE, + outcome == SessionTouchOutcome.UNAVAILABLE, + outcome == SessionTouchOutcome.STALE_REVISION + || outcome == SessionTouchOutcome.MUTATION_CONFLICT + || outcome == SessionTouchOutcome.TOMBSTONED); + } + + private static RedisCapabilityObserver.Classification classifyRevoke( + SessionRevokeOutcome outcome) { + return classifyMutation( + outcome == SessionRevokeOutcome.REVOKED_AND_DELETED + || outcome == SessionRevokeOutcome.TOMBSTONED_ABSENT + || outcome == SessionRevokeOutcome.ALREADY_REVOKED_SAME_OPERATION, + outcome == SessionRevokeOutcome.INDETERMINATE, + outcome == SessionRevokeOutcome.UNAVAILABLE, + outcome == SessionRevokeOutcome.STALE_REVISION + || outcome == SessionRevokeOutcome.OPERATION_CONFLICT); + } + + private static RedisCapabilityObserver.Classification classifyRotate( + SessionRotateOutcome outcome) { + return classifyMutation( + outcome == SessionRotateOutcome.ROTATED + || outcome == SessionRotateOutcome.ALREADY_ROTATED_SAME_OPERATION, + outcome == SessionRotateOutcome.INDETERMINATE, + outcome == SessionRotateOutcome.UNAVAILABLE, + outcome == SessionRotateOutcome.STALE_REVISION + || outcome == SessionRotateOutcome.OLD_TOMBSTONED + || outcome == SessionRotateOutcome.NEW_ID_CONFLICT); + } + + private static RedisCapabilityObserver.Classification classifyMutation( + boolean success, boolean indeterminate, boolean unavailable, boolean conflict) { + if (success) { + return definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + } + if (indeterminate) { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + if (unavailable) { + return unavailable(); + } + return definite( + conflict + ? RedisCapabilityObservationEvent.Outcome.CONFLICT + : RedisCapabilityObservationEvent.Outcome.MISS); + } + + private static RedisCapabilityObserver.Classification definite( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + + private static RedisCapabilityObserver.Classification unavailable() { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static List copy(List values) { + return values.stream().map(byte[]::clone).toList(); + } + + private static byte[] base64(byte[] value) { + return BASE64.encode(value); + } + + private static byte[] ascii(long value) { + return ascii(Long.toString(value)); + } + + private static byte[] ascii(Instant value) { + return ascii(value.toEpochMilli()); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static String digest(byte[] value) { + try { + return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException( + "SHA-256 unavailable for Redis session payload digest", exception); + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Redis session key material is closed"); + } + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + boolean destroyed() { + return closed.get() + && java.util.stream.IntStream.range(0, hmacSecret.length) + .allMatch(index -> hmacSecret[index] == 0); + } + + private static RedisSessionProgramCompatibilityException incompatible( + RedisProgramId program, String detail) { + return new RedisSessionProgramCompatibilityException( + program == null ? "" : program.externalId(), detail); + } + + private static final class RedisKeyPair { + + private final byte[] live; + private final byte[] tombstone; + private final String resourceDigest; + + private RedisKeyPair(byte[] live, byte[] tombstone, String resourceDigest) { + this.live = live.clone(); + this.tombstone = tombstone.clone(); + this.resourceDigest = resourceDigest; + } + + private byte[] live() { + return live.clone(); + } + + private byte[] tombstone() { + return tombstone.clone(); + } + + private String resourceDigest() { + return resourceDigest; + } + } +} + +final class RedisSessionProgramCompatibilityException extends RuntimeException { + + RedisSessionProgramCompatibilityException(String program, String detail) { + super("Redis session program reply is incompatible: " + program + " " + detail); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientFactory.java new file mode 100644 index 0000000..b16543d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientFactory.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisURI; +import io.lettuce.core.cluster.ClusterClientOptions; +import java.util.List; + +/** Injectable native-client creation seam for deterministic, network-free topology tests. */ +interface RedisNativeClientFactory { + + RedisNativeClientHandle openStandalone( + RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings); + + RedisNativeClientHandle openCluster( + List seedUris, ClusterClientOptions options, RedisClientRuntimeSettings settings); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientHandle.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientHandle.java new file mode 100644 index 0000000..f9a7779 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisNativeClientHandle.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; + +/** Package-private lifecycle handle that prevents native command APIs from escaping. */ +interface RedisNativeClientHandle { + + Class nativeClientType(); + + void close(Duration timeout); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOwnedPhysicalKeyMaterial.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOwnedPhysicalKeyMaterial.java new file mode 100644 index 0000000..2f64e19 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOwnedPhysicalKeyMaterial.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Closed key material created only inside the semantic capability that owns the key. */ +sealed interface RedisOwnedPhysicalKeyMaterial + permits LettuceRedisRuntime.LegacyKeyMaterial, + RedisRoleCommandRouter.LegacyKeyMaterial, + RedisStringCacheRegion.CacheKeyMaterial, + RedisCacheConsistencyStore.ConsistencyKeyMaterial, + RedisSemanticReadinessProbe.ProbeKeyMaterial { + + byte[] copyEncodedKey(); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKey.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKey.java new file mode 100644 index 0000000..b330269 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKey.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** Opaque adapter-private physical key. Command ports never accept caller-owned key bytes. */ +final class RedisPhysicalKey { + + private static final int MAXIMUM_KEY_BYTES = 1_024; + + private final byte[] encoded; + + private RedisPhysicalKey(byte[] encoded) { + Objects.requireNonNull(encoded, "Redis physical key must be non-null"); + if (encoded.length < 1 || encoded.length > MAXIMUM_KEY_BYTES) { + throw new IllegalArgumentException("Redis physical key is out of bounds"); + } + this.encoded = encoded.clone(); + } + + static RedisPhysicalKey owned(RedisOwnedPhysicalKeyMaterial material) { + return new RedisPhysicalKey( + Objects.requireNonNull(material, "key material must be non-null").copyEncodedKey()); + } + + static RedisPhysicalKey primitive(RedisPrimitiveKey key) { + Objects.requireNonNull(key, "primitive key must be non-null"); + String encoded = + "ca:primitive:" + + key.family() + + ":v" + + key.version() + + ":{" + + key.slot() + + "}:" + + key.identity(); + return new RedisPhysicalKey(encoded.getBytes(StandardCharsets.UTF_8)); + } + + int encodedLength() { + return encoded.length; + } + + private byte[] copyEncoded() { + return encoded.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof RedisPhysicalKey candidate && Arrays.equals(encoded, candidate.encoded); + } + + @Override + public int hashCode() { + return Arrays.hashCode(encoded); + } + + @Override + public String toString() { + return "RedisPhysicalKey[redacted]"; + } + + /** Sole terminal unwrap; callers cannot supply or replace key bytes through this API. */ + static final class WireCodec { + + private WireCodec() {} + + static byte[] copy(RedisPhysicalKey key) { + return Objects.requireNonNull(key, "key must be non-null").copyEncoded(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalog.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalog.java new file mode 100644 index 0000000..defb698 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalog.java @@ -0,0 +1,204 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.time.Duration; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; + +final class RedisPrimitiveCatalog { + + private static final int SCHEMA_REVISION = 1; + + private final Map descriptors; + + private RedisPrimitiveCatalog(Map descriptors) { + this.descriptors = Map.copyOf(descriptors); + } + + static RedisPrimitiveCatalog standard() { + Map values = new EnumMap<>(RedisPrimitiveId.class); + for (RedisPrimitiveId id : RedisPrimitiveId.values()) { + values.put(id, compileDescriptor(id)); + } + return new RedisPrimitiveCatalog(values); + } + + RedisPrimitiveDescriptor descriptor(RedisPrimitiveId id) { + RedisPrimitiveDescriptor descriptor = descriptors.get(id); + if (descriptor == null) { + throw new IllegalArgumentException("unknown primitive id"); + } + return descriptor; + } + + Collection descriptors() { + return descriptors.values(); + } + + RedisPrimitiveKeyFactory keyFactory(RedisPrimitiveId id) { + return RedisPrimitiveKeyFactory.canonical(this, id); + } + + int schemaRevision() { + return SCHEMA_REVISION; + } + + RedisStringValuePrimitives strings(RedisPrimitiveCommands commands) { + return new RedisStringValuePrimitives(this, commands); + } + + RedisCounterPrimitives counters(RedisPrimitiveCommands commands) { + return new RedisCounterPrimitives(this, commands); + } + + RedisHashPrimitives hashes(RedisPrimitiveCommands commands) { + return new RedisHashPrimitives(this, commands); + } + + RedisSetPrimitives sets(RedisPrimitiveCommands commands) { + return new RedisSetPrimitives(this, commands); + } + + RedisSortedSetPrimitives sortedSets(RedisPrimitiveCommands commands) { + return new RedisSortedSetPrimitives(this, commands); + } + + RedisListPrimitives lists(RedisPrimitiveCommands commands) { + return new RedisListPrimitives(this, commands); + } + + RedisBitmapPrimitives bitmaps(RedisPrimitiveCommands commands) { + return new RedisBitmapPrimitives(this, commands); + } + + RedisHyperLogLogPrimitives hyperLogLogs(RedisPrimitiveCommands commands) { + return new RedisHyperLogLogPrimitives(this, commands); + } + + RedisGeoPrimitives geo(RedisPrimitiveCommands commands) { + return new RedisGeoPrimitives(this, commands); + } + + private static RedisPrimitiveDescriptor compileDescriptor(RedisPrimitiveId id) { + RedisPrimitiveSemanticClass semantic = + switch (id.structure()) { + case LIST -> RedisPrimitiveSemanticClass.BEST_EFFORT_NOT_MESSAGING; + case BITMAP -> RedisPrimitiveSemanticClass.NON_AUTHORITATIVE_FIXED_DOMAIN_BITMAP; + case HYPERLOGLOG -> RedisPrimitiveSemanticClass.APPROXIMATE_NON_AUTHORITATIVE_HLL; + case GEO -> RedisPrimitiveSemanticClass.PRIVACY_SENSITIVE_NON_AUTHORITATIVE_GEO; + default -> RedisPrimitiveSemanticClass.EXACT; + }; + RedisRole role = + id.structure() == RedisPrimitiveStructure.COUNTER + ? RedisRole.COORDINATION + : RedisRole.CACHE; + boolean bulk = + switch (id) { + case STRING_MGET, HLL_MERGE_SAME_SLOT -> true; + default -> false; + }; + boolean read = + switch (id) { + case STRING_GET, + STRING_MGET, + COUNTER_READ, + HASH_GET, + HASH_MGET, + HASH_SCAN_PAGE, + SET_CONTAINS, + SET_CARDINALITY, + SET_SCAN_PAGE, + ZSET_COUNT, + ZSET_RANK_PAGE, + ZSET_SCORE_PAGE, + BITMAP_GET, + BITMAP_COUNT_FIXED_RANGE, + HLL_COUNT, + GEO_SEARCH -> + true; + default -> false; + }; + RedisProgramId programId = + switch (id) { + case STRING_GET -> RedisProgramId.BOUNDED_GET_V1; + case STRING_MGET -> RedisProgramId.BOUNDED_MGET_V1; + case STRING_COMPARE_SET -> RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1; + case STRING_COMPARE_DELETE -> RedisProgramId.COMPARE_AND_DELETE; + case COUNTER_INCREMENT_INITIAL_TTL -> RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1; + case HASH_SET_FIELDS -> RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1; + case HASH_SCAN_PAGE -> RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1; + case HASH_REVISION_CAS -> RedisProgramId.HASH_REVISION_CAS_V1; + case SET_ADMIT -> RedisProgramId.BOUNDED_SET_ADMISSION_V1; + case SET_SCAN_PAGE -> RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1; + case ZSET_ADD -> RedisProgramId.BOUNDED_ZSET_ADMISSION_V1; + case ZSET_TRIM_BOUNDED -> RedisProgramId.ZSET_BOUNDED_TRIM_V1; + case LIST_ADMIT -> RedisProgramId.BOUNDED_LIST_ADMISSION_V1; + case LIST_TRIM_FIXED_BOUNDS -> RedisProgramId.GUARDED_LIST_TRIM_V1; + case GEO_ADD -> RedisProgramId.BOUNDED_GEO_ADMISSION_V1; + default -> null; + }; + RedisPrimitiveDescriptor.TtlPolicy ttl = + read + ? RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING + : id == RedisPrimitiveId.STRING_COMPARE_DELETE + ? RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING + : id == RedisPrimitiveId.ZSET_TRIM_BOUNDED + || id == RedisPrimitiveId.LIST_TRIM_FIXED_BOUNDS + ? RedisPrimitiveDescriptor.TtlPolicy.REQUIRE_PRECREATED_EXPIRING_KEY + : programId != null + ? RedisPrimitiveDescriptor.TtlPolicy.ATOMIC_INITIAL_TTL + : switch (id) { + case STRING_SET_PX, STRING_SET_NX_PX, STRING_SET_XX_PX -> + RedisPrimitiveDescriptor.TtlPolicy.REQUIRED_PX; + case BITMAP_SET, HLL_ADD, HLL_MERGE_SAME_SLOT -> + RedisPrimitiveDescriptor.TtlPolicy.PERSISTENT_ONLY; + default -> RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING; + }; + return new RedisPrimitiveDescriptor( + id, + id.structure(), + semantic, + role, + family(id.structure()), + 1, + 512, + 16_000, + 1_024, + 4_096, + switch (id) { + case STRING_MGET, HLL_MERGE_SAME_SLOT -> 4; + default -> 1; + }, + 256, + 16_384, + 49_152, + ttl, + bulk + ? RedisPrimitiveDescriptor.SlotRule.SAME_SLOT + : RedisPrimitiveDescriptor.SlotRule.SINGLE_KEY, + Duration.ofSeconds(2), + read + ? RedisPrimitiveDescriptor.RetrySafety.SAFE_READ + : RedisPrimitiveDescriptor.RetrySafety.NON_REPLAY_SAFE_MUTATION, + read + ? RedisPrimitiveDescriptor.TimeoutCertainty.NOT_APPLIED_FOR_READ + : RedisPrimitiveDescriptor.TimeoutCertainty.INDETERMINATE_FOR_MUTATION, + "redis.primitive." + id.name().toLowerCase(java.util.Locale.ROOT).replace('_', '.'), + programId); + } + + private static String family(RedisPrimitiveStructure structure) { + return switch (structure) { + case STRING -> "string-value"; + case COUNTER -> "counter"; + case HASH -> "hash"; + case SET -> "set"; + case SORTED_SET -> "sorted-set"; + case LIST -> "list"; + case BITMAP -> "bitmap"; + case HYPERLOGLOG -> "hyperloglog"; + case GEO -> "geo"; + }; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommands.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommands.java new file mode 100644 index 0000000..904d7f1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommands.java @@ -0,0 +1,7 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** One closed primitive transport boundary; implementations switch only on RedisPrimitiveId. */ +interface RedisPrimitiveCommands { + + RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCursor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCursor.java new file mode 100644 index 0000000..b7a33c4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCursor.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** Bounded maintenance cursor with explicit route/key/schema ownership. */ +record RedisPrimitiveCursor( + int catalogRevision, + long routeEpoch, + String keyFamily, + String physicalKeyDigest, + String rawCursor, + ObservationSemantics observationSemantics) { + + enum ObservationSemantics { + DUPLICATES_POSSIBLE_MUTATIONS_UNDEFINED + } + + RedisPrimitiveCursor { + if (catalogRevision < 1 || routeEpoch < 0) { + throw new IllegalArgumentException("primitive cursor ownership is invalid"); + } + Objects.requireNonNull(keyFamily, "keyFamily must be non-null"); + Objects.requireNonNull(physicalKeyDigest, "physicalKeyDigest must be non-null"); + Objects.requireNonNull(rawCursor, "rawCursor must be non-null"); + Objects.requireNonNull(observationSemantics, "observationSemantics must be non-null"); + if (!physicalKeyDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("primitive cursor key digest is invalid"); + } + if (!rawCursor.matches("0|[1-9][0-9]{0,19}")) { + throw new IllegalArgumentException("primitive cursor token is invalid"); + } + } + + static RedisPrimitiveCursor initial( + RedisPrimitiveCatalog catalog, + RedisPrimitiveDescriptor descriptor, + RedisPrimitiveKey key, + long routeEpoch) { + return new RedisPrimitiveCursor( + catalog.schemaRevision(), + routeEpoch, + descriptor.keyFamily(), + digest(key), + "0", + ObservationSemantics.DUPLICATES_POSSIBLE_MUTATIONS_UNDEFINED); + } + + void validateFor( + RedisPrimitiveCatalog catalog, + RedisPrimitiveDescriptor descriptor, + RedisPrimitiveKey key, + long expectedRouteEpoch) { + if (catalogRevision != catalog.schemaRevision() + || routeEpoch != expectedRouteEpoch + || !keyFamily.equals(descriptor.keyFamily()) + || !physicalKeyDigest.equals(digest(key))) { + throw new IllegalArgumentException("primitive cursor does not belong to this route/key"); + } + } + + RedisPrimitiveCursor advance(String nextRawCursor) { + return new RedisPrimitiveCursor( + catalogRevision, + routeEpoch, + keyFamily, + physicalKeyDigest, + nextRawCursor, + observationSemantics); + } + + private static String digest(RedisPrimitiveKey key) { + Objects.requireNonNull(key, "primitive cursor key must be non-null"); + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(RedisPhysicalKey.WireCodec.copy(key.physicalKey()))); + } catch (NoSuchAlgorithmException exception) { + throw new LinkageError("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptor.java new file mode 100644 index 0000000..1d45159 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptor.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +record RedisPrimitiveDescriptor( + RedisPrimitiveId id, + RedisPrimitiveStructure structure, + RedisPrimitiveSemanticClass semanticClass, + RedisRole boundRole, + String keyFamily, + int keyVersion, + int maximumKeyBytes, + int maximumValueBytes, + int maximumFieldBytes, + int maximumMemberBytes, + int maximumKeys, + int maximumElements, + int maximumEncodedBytes, + int maximumResultBytes, + TtlPolicy ttlPolicy, + SlotRule slotRule, + Duration totalDeadline, + RetrySafety retrySafety, + TimeoutCertainty timeoutCertainty, + String lowCardinalityOperation, + RedisProgramId programId) { + + enum TtlPolicy { + REQUIRED_PX, + ATOMIC_INITIAL_TTL, + PRESERVE_EXISTING, + REQUIRE_PRECREATED_EXPIRING_KEY, + PERSISTENT_ONLY + } + + enum SlotRule { + SINGLE_KEY, + SAME_SLOT + } + + enum RetrySafety { + SAFE_READ, + IDEMPOTENT_MUTATION, + NON_REPLAY_SAFE_MUTATION + } + + enum TimeoutCertainty { + NOT_APPLIED_FOR_READ, + INDETERMINATE_FOR_MUTATION + } + + RedisPrimitiveDescriptor { + Objects.requireNonNull(id, "id must be non-null"); + if (structure != id.structure()) { + throw new IllegalArgumentException("primitive structure does not match id"); + } + Objects.requireNonNull(semanticClass, "semanticClass must be non-null"); + Objects.requireNonNull(boundRole, "boundRole must be non-null"); + if (keyFamily == null || !keyFamily.matches("[a-z][a-z0-9-]{2,31}")) { + throw new IllegalArgumentException("primitive key family is invalid"); + } + if (keyVersion < 1 + || maximumKeyBytes < 16 + || maximumKeyBytes > 512 + || maximumValueBytes < 1 + || maximumValueBytes > 1_048_576 + || maximumFieldBytes < 1 + || maximumFieldBytes > 1_024 + || maximumMemberBytes < 1 + || maximumMemberBytes > 4_096 + || maximumKeys < 1 + || maximumKeys > 32 + || maximumElements < 1 + || maximumElements > 1_024 + || maximumEncodedBytes < 1 + || maximumEncodedBytes > 4_194_304 + || maximumResultBytes < 1 + || maximumResultBytes > 4_194_304) { + throw new IllegalArgumentException("primitive descriptor bounds are invalid"); + } + Objects.requireNonNull(ttlPolicy, "ttlPolicy must be non-null"); + Objects.requireNonNull(slotRule, "slotRule must be non-null"); + if (totalDeadline == null + || totalDeadline.isZero() + || totalDeadline.isNegative() + || totalDeadline.compareTo(Duration.ofSeconds(5)) > 0) { + throw new IllegalArgumentException("primitive total deadline is invalid"); + } + Objects.requireNonNull(retrySafety, "retrySafety must be non-null"); + Objects.requireNonNull(timeoutCertainty, "timeoutCertainty must be non-null"); + if (lowCardinalityOperation == null + || !lowCardinalityOperation.matches("redis\\.primitive\\.[a-z0-9.-]+")) { + throw new IllegalArgumentException("primitive operation identity is invalid"); + } + } + + List validateKeys(List keys) { + Objects.requireNonNull(keys, "keys must be non-null"); + if (keys.isEmpty() || keys.size() > maximumKeys) { + throw new IllegalArgumentException("primitive key count exceeds descriptor bounds"); + } + String expectedSlot = null; + for (RedisPrimitiveKey key : keys) { + if (!keyFamily.equals(key.family()) + || keyVersion != key.version() + || key.encodedLength() > maximumKeyBytes) { + throw new IllegalArgumentException("primitive key family or bounds are incompatible"); + } + if (expectedSlot == null) { + expectedSlot = key.slot(); + } else if (slotRule == SlotRule.SAME_SLOT && !expectedSlot.equals(key.slot())) { + throw new IllegalArgumentException("primitive keys must use the same slot"); + } + } + if (slotRule == SlotRule.SINGLE_KEY && keys.size() != 1) { + throw new IllegalArgumentException("primitive requires a single key"); + } + return List.copyOf(keys); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveElementResult.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveElementResult.java new file mode 100644 index 0000000..2f7d9db --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveElementResult.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; +import java.util.Optional; + +/** Per-element result keeps missing/wrong-type ambiguity or value presence explicit. */ +record RedisPrimitiveElementResult(Status status, Optional value) { + + enum Status { + PRESENT, + MISSING, + ABSENT_OR_WRONG_TYPE + } + + RedisPrimitiveElementResult { + Objects.requireNonNull(status, "element status must be non-null"); + Objects.requireNonNull(value, "element value must be non-null"); + if ((status == Status.PRESENT) != value.isPresent()) { + throw new IllegalArgumentException("element status and value disagree"); + } + } + + static RedisPrimitiveElementResult present(RedisPrimitiveValue value) { + return new RedisPrimitiveElementResult(Status.PRESENT, Optional.of(value)); + } + + static RedisPrimitiveElementResult missing() { + return new RedisPrimitiveElementResult(Status.MISSING, Optional.empty()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveExecutor.java new file mode 100644 index 0000000..c270ac4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveExecutor.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; +import java.util.Objects; +import java.util.function.LongSupplier; + +/** Validates catalog identity and dispatches through one admitted primitive command boundary. */ +final class RedisPrimitiveExecutor { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveCommands commands; + private final LongSupplier ticker; + + RedisPrimitiveExecutor(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this(catalog, commands, System::nanoTime); + } + + RedisPrimitiveExecutor( + RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands, LongSupplier ticker) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null"); + } + + RedisPrimitiveReply execute( + RedisPrimitiveId id, + List keys, + RedisPrimitiveInvocation.Arguments arguments) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(id); + RedisPrimitiveInvocation invocation = + new RedisPrimitiveInvocation(catalog, descriptor, keys, arguments, ticker); + invocation.remainingDeadline(); + return commands.execute(invocation); + } + + RedisPrimitiveMutationResult mutate( + RedisPrimitiveId id, + List keys, + RedisPrimitiveInvocation.Arguments arguments) { + try { + return RedisPrimitiveMutationResult.from(execute(id, keys, arguments)); + } catch (RedisCommandFailureException failure) { + return RedisPrimitiveMutationResult.failed(failure); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashEntry.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashEntry.java new file mode 100644 index 0000000..9d23515 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashEntry.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** One binary-safe HSCAN field/value pair. */ +record RedisPrimitiveHashEntry(RedisPrimitiveValue field, RedisPrimitiveValue value) { + + RedisPrimitiveHashEntry { + if (field == null || value == null) { + throw new IllegalArgumentException("hash scan entry must be complete"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveId.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveId.java new file mode 100644 index 0000000..a072b42 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveId.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +enum RedisPrimitiveId { + STRING_GET(RedisPrimitiveStructure.STRING), + STRING_MGET(RedisPrimitiveStructure.STRING), + STRING_SET_PX(RedisPrimitiveStructure.STRING), + STRING_SET_NX_PX(RedisPrimitiveStructure.STRING), + STRING_SET_XX_PX(RedisPrimitiveStructure.STRING), + STRING_COMPARE_SET(RedisPrimitiveStructure.STRING), + STRING_COMPARE_DELETE(RedisPrimitiveStructure.STRING), + COUNTER_READ(RedisPrimitiveStructure.COUNTER), + COUNTER_INCREMENT_INITIAL_TTL(RedisPrimitiveStructure.COUNTER), + HASH_GET(RedisPrimitiveStructure.HASH), + HASH_MGET(RedisPrimitiveStructure.HASH), + HASH_SET_FIELDS(RedisPrimitiveStructure.HASH), + HASH_DELETE_FIELDS(RedisPrimitiveStructure.HASH), + HASH_SCAN_PAGE(RedisPrimitiveStructure.HASH), + HASH_REVISION_CAS(RedisPrimitiveStructure.HASH), + SET_CONTAINS(RedisPrimitiveStructure.SET), + SET_REMOVE(RedisPrimitiveStructure.SET), + SET_CARDINALITY(RedisPrimitiveStructure.SET), + SET_SCAN_PAGE(RedisPrimitiveStructure.SET), + SET_ADMIT(RedisPrimitiveStructure.SET), + ZSET_ADD(RedisPrimitiveStructure.SORTED_SET), + ZSET_REMOVE(RedisPrimitiveStructure.SORTED_SET), + ZSET_COUNT(RedisPrimitiveStructure.SORTED_SET), + ZSET_RANK_PAGE(RedisPrimitiveStructure.SORTED_SET), + ZSET_SCORE_PAGE(RedisPrimitiveStructure.SORTED_SET), + ZSET_TRIM_BOUNDED(RedisPrimitiveStructure.SORTED_SET), + LIST_POP(RedisPrimitiveStructure.LIST), + LIST_TRIM_FIXED_BOUNDS(RedisPrimitiveStructure.LIST), + LIST_ADMIT(RedisPrimitiveStructure.LIST), + BITMAP_GET(RedisPrimitiveStructure.BITMAP), + BITMAP_SET(RedisPrimitiveStructure.BITMAP), + BITMAP_COUNT_FIXED_RANGE(RedisPrimitiveStructure.BITMAP), + HLL_ADD(RedisPrimitiveStructure.HYPERLOGLOG), + HLL_COUNT(RedisPrimitiveStructure.HYPERLOGLOG), + HLL_MERGE_SAME_SLOT(RedisPrimitiveStructure.HYPERLOGLOG), + GEO_ADD(RedisPrimitiveStructure.GEO), + GEO_SEARCH(RedisPrimitiveStructure.GEO); + + private final RedisPrimitiveStructure structure; + + RedisPrimitiveId(RedisPrimitiveStructure structure) { + this.structure = structure; + } + + RedisPrimitiveStructure structure() { + return structure; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocation.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocation.java new file mode 100644 index 0000000..f3e2c28 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocation.java @@ -0,0 +1,771 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.function.LongSupplier; + +/** Closed descriptor-owned invocation; no Redis command name, raw key or Lua source is carried. */ +final class RedisPrimitiveInvocation { + + enum WriteCondition { + ALWAYS, + IF_ABSENT, + IF_PRESENT + } + + sealed interface Arguments + permits NoArguments, + BinaryArguments, + ExpiringWrite, + ProgramArguments, + ScanArguments, + RangeArguments, + ScoreRangeArguments, + SortedSetArguments, + BitmapArguments, + BitmapCountArguments, + GeoArguments { + + int encodedBytes(); + } + + enum NoArguments implements Arguments { + INSTANCE; + + @Override + public int encodedBytes() { + return 0; + } + } + + record BinaryArguments(List values) implements Arguments { + + BinaryArguments { + values = List.copyOf(Objects.requireNonNull(values, "values must be non-null")); + if (values.isEmpty()) { + throw new IllegalArgumentException("primitive binary arguments must not be empty"); + } + } + + @Override + public int encodedBytes() { + return checkedBytes(values); + } + } + + record ExpiringWrite( + RedisPrimitiveValue value, RedisTtlMillis timeToLive, WriteCondition condition) + implements Arguments { + + ExpiringWrite(RedisPrimitiveValue value, Duration timeToLive, WriteCondition condition) { + this(value, RedisTtlMillis.from(timeToLive), condition); + } + + ExpiringWrite { + Objects.requireNonNull(value, "value must be non-null"); + Objects.requireNonNull(timeToLive, "timeToLive must be non-null"); + Objects.requireNonNull(condition, "condition must be non-null"); + } + + @Override + public int encodedBytes() { + return Math.addExact(value.encodedLength(), Long.BYTES); + } + } + + sealed interface ProgramArguments extends Arguments + permits AtomicArguments, + CounterArguments, + CapacityArguments, + CompareSetArguments, + HashAdmissionArguments, + HashRevisionArguments, + SortedSetAdmissionArguments, + GeoAdmissionArguments, + MgetArguments, + ScanPageArguments { + + List programValues(); + + @Override + default int encodedBytes() { + return checkedBytes(programValues()); + } + } + + record AtomicArguments(List values) implements ProgramArguments { + + AtomicArguments { + values = List.copyOf(Objects.requireNonNull(values, "values must be non-null")); + if (values.isEmpty()) { + throw new IllegalArgumentException("primitive atomic arguments must not be empty"); + } + } + + @Override + public List programValues() { + return values; + } + } + + record CounterArguments(long delta, long minimum, long maximum, RedisTtlMillis initialTimeToLive) + implements ProgramArguments { + + CounterArguments(long delta, long minimum, long maximum, Duration initialTimeToLive) { + this(delta, minimum, maximum, RedisTtlMillis.from(initialTimeToLive)); + } + + CounterArguments { + if (delta == 0 || minimum > maximum) { + throw new IllegalArgumentException("counter bounds are invalid"); + } + Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + ascii(Long.toString(delta)), + ascii(Long.toString(minimum)), + ascii(Long.toString(maximum)), + ascii(Long.toString(initialTimeToLive.value()))); + } + } + + record CapacityArguments( + RedisPrimitiveValue value, RedisPrimitiveLimit capacity, RedisTtlMillis initialTimeToLive) + implements ProgramArguments { + + CapacityArguments( + RedisPrimitiveValue value, RedisPrimitiveLimit capacity, Duration initialTimeToLive) { + this(value, capacity, RedisTtlMillis.from(initialTimeToLive)); + } + + CapacityArguments { + Objects.requireNonNull(value, "value must be non-null"); + Objects.requireNonNull(capacity, "capacity must be non-null"); + Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + value, + ascii(Integer.toString(capacity.value())), + ascii(Long.toString(initialTimeToLive.value()))); + } + } + + record CompareSetArguments( + ExpectedKind expectedKind, + RedisPrimitiveValue expectedValue, + RedisPrimitiveValue newValue, + RedisTtlMillis timeToLive) + implements ProgramArguments { + + enum ExpectedKind { + ABSENT, + VALUE + } + + CompareSetArguments( + ExpectedKind expectedKind, + RedisPrimitiveValue expectedValue, + RedisPrimitiveValue newValue, + Duration timeToLive) { + this(expectedKind, expectedValue, newValue, RedisTtlMillis.from(timeToLive)); + } + + CompareSetArguments { + Objects.requireNonNull(expectedKind, "expectedKind must be non-null"); + Objects.requireNonNull(newValue, "newValue must be non-null"); + if ((expectedKind == ExpectedKind.ABSENT && expectedValue != null) + || (expectedKind == ExpectedKind.VALUE && expectedValue == null)) { + throw new IllegalArgumentException("compare-set expectation is invalid"); + } + Objects.requireNonNull(timeToLive, "timeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + ascii(expectedKind.name()), + expectedKind == ExpectedKind.ABSENT ? ascii("-") : expectedValue, + newValue, + ascii(Long.toString(timeToLive.value()))); + } + } + + record HashAdmissionArguments( + RedisPrimitiveValue field, + RedisPrimitiveValue value, + RedisPrimitiveLimit capacity, + RedisTtlMillis initialTimeToLive) + implements ProgramArguments { + + HashAdmissionArguments( + RedisPrimitiveValue field, + RedisPrimitiveValue value, + RedisPrimitiveLimit capacity, + Duration initialTimeToLive) { + this(field, value, capacity, RedisTtlMillis.from(initialTimeToLive)); + } + + HashAdmissionArguments { + Objects.requireNonNull(field, "field must be non-null"); + Objects.requireNonNull(value, "value must be non-null"); + Objects.requireNonNull(capacity, "capacity must be non-null"); + Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + field, + value, + ascii(Integer.toString(capacity.value())), + ascii(Long.toString(initialTimeToLive.value()))); + } + } + + record HashRevisionArguments( + ExpectedKind expectedKind, + String expectedRevision, + String nextRevision, + RedisPrimitiveValue value, + RedisTtlMillis initialTimeToLive) + implements ProgramArguments { + + enum ExpectedKind { + ABSENT, + VALUE + } + + HashRevisionArguments( + ExpectedKind expectedKind, + String expectedRevision, + String nextRevision, + RedisPrimitiveValue value, + Duration initialTimeToLive) { + this( + expectedKind, + expectedRevision, + nextRevision, + value, + RedisTtlMillis.from(initialTimeToLive)); + } + + HashRevisionArguments { + Objects.requireNonNull(expectedKind, "expectedKind must be non-null"); + Objects.requireNonNull(expectedRevision, "expectedRevision must be non-null"); + Objects.requireNonNull(nextRevision, "nextRevision must be non-null"); + Objects.requireNonNull(value, "value must be non-null"); + if ((expectedKind == ExpectedKind.ABSENT && !expectedRevision.isEmpty()) + || (expectedKind == ExpectedKind.VALUE && !token(expectedRevision)) + || !token(nextRevision) + || expectedRevision.equals(nextRevision)) { + throw new IllegalArgumentException("hash revision token is invalid"); + } + Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + ascii(expectedKind.name()), + ascii(expectedRevision.isEmpty() ? "-" : expectedRevision), + ascii(nextRevision), + value, + ascii(Long.toString(initialTimeToLive.value()))); + } + } + + record SortedSetAdmissionArguments( + RedisPrimitiveValue member, + RedisSortedSetScore score, + RedisPrimitiveLimit capacity, + RedisTtlMillis initialTimeToLive) + implements ProgramArguments { + + SortedSetAdmissionArguments( + RedisPrimitiveValue member, + RedisSortedSetScore score, + RedisPrimitiveLimit capacity, + Duration initialTimeToLive) { + this(member, score, capacity, RedisTtlMillis.from(initialTimeToLive)); + } + + SortedSetAdmissionArguments { + Objects.requireNonNull(member, "member must be non-null"); + Objects.requireNonNull(score, "score must be non-null"); + Objects.requireNonNull(capacity, "capacity must be non-null"); + Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + member, + ascii(score.canonical()), + ascii(Integer.toString(capacity.value())), + ascii(Long.toString(initialTimeToLive.value()))); + } + } + + record GeoAdmissionArguments( + RedisPrimitiveValue member, + RedisGeoCoordinate coordinate, + RedisPrimitiveLimit capacity, + RedisTtlMillis initialTimeToLive) + implements ProgramArguments { + + GeoAdmissionArguments( + RedisPrimitiveValue member, + RedisGeoCoordinate coordinate, + RedisPrimitiveLimit capacity, + Duration initialTimeToLive) { + this(member, coordinate, capacity, RedisTtlMillis.from(initialTimeToLive)); + } + + GeoAdmissionArguments { + Objects.requireNonNull(member, "member must be non-null"); + Objects.requireNonNull(coordinate, "coordinate must be non-null"); + Objects.requireNonNull(capacity, "capacity must be non-null"); + Objects.requireNonNull(initialTimeToLive, "initialTimeToLive must be non-null"); + } + + @Override + public List programValues() { + return List.of( + member, + ascii(coordinate.canonicalLongitude()), + ascii(coordinate.canonicalLatitude()), + ascii(Integer.toString(capacity.value())), + ascii(Long.toString(initialTimeToLive.value()))); + } + } + + record MgetArguments(int requestedKeyCount, int maximumResultBytes, int maximumValueBytes) + implements ProgramArguments { + + MgetArguments { + if (requestedKeyCount < 1 + || requestedKeyCount > 4 + || maximumResultBytes < 1 + || maximumResultBytes > 2_097_152 + || maximumValueBytes < 1 + || maximumValueBytes > 1_048_576) { + throw new IllegalArgumentException("bounded MGET arguments are invalid"); + } + } + + @Override + public List programValues() { + return List.of( + ascii(Integer.toString(requestedKeyCount)), + ascii(Integer.toString(maximumResultBytes)), + ascii(Integer.toString(maximumValueBytes))); + } + } + + record ScanPageArguments( + RedisPrimitiveCursor cursor, int corruptionCeiling, int maximumResultBytes) + implements ProgramArguments { + + ScanPageArguments { + Objects.requireNonNull(cursor, "cursor must be non-null"); + if (corruptionCeiling < 1 + || corruptionCeiling > 1024 + || maximumResultBytes < 1 + || maximumResultBytes > 2_097_152) { + throw new IllegalArgumentException("bounded scan arguments are invalid"); + } + } + + @Override + public List programValues() { + return List.of( + ascii(cursor.rawCursor()), + ascii(Integer.toString(corruptionCeiling)), + ascii(Integer.toString(maximumResultBytes))); + } + } + + record ScanArguments(RedisPrimitiveCursor cursor, RedisPrimitiveLimit limit, long routeEpoch) + implements Arguments { + + ScanArguments { + Objects.requireNonNull(cursor, "cursor must be non-null"); + Objects.requireNonNull(limit, "limit must be non-null"); + if (routeEpoch < 0) { + throw new IllegalArgumentException("route epoch must be non-negative"); + } + } + + @Override + public int encodedBytes() { + return Math.addExact(cursor.rawCursor().length(), Integer.BYTES); + } + } + + record RangeArguments(long first, long last, RedisPrimitiveLimit limit) implements Arguments { + + RangeArguments { + Objects.requireNonNull(limit, "limit must be non-null"); + if (first < 0 || last < first) { + throw new IllegalArgumentException("primitive range is invalid"); + } + } + + @Override + public int encodedBytes() { + return Long.BYTES * 2 + Integer.BYTES; + } + } + + record ScoreRangeArguments( + RedisSortedSetScore minimum, + RedisSortedSetScore maximum, + long offset, + RedisPrimitiveLimit limit) + implements Arguments { + + ScoreRangeArguments { + Objects.requireNonNull(minimum, "minimum score must be non-null"); + Objects.requireNonNull(maximum, "maximum score must be non-null"); + Objects.requireNonNull(limit, "limit must be non-null"); + if (offset < 0) { + throw new IllegalArgumentException("sorted-set score range offset must be non-negative"); + } + } + + @Override + public int encodedBytes() { + return Math.addExact( + minimum.canonical().length() + maximum.canonical().length(), Long.BYTES + Integer.BYTES); + } + } + + record SortedSetArguments( + RedisPrimitiveValue member, RedisSortedSetScore score, RedisPrimitiveLimit capacity) + implements Arguments { + + SortedSetArguments { + Objects.requireNonNull(member, "member must be non-null"); + Objects.requireNonNull(score, "score must be non-null"); + Objects.requireNonNull(capacity, "capacity must be non-null"); + } + + @Override + public int encodedBytes() { + return Math.addExact(member.encodedLength(), score.canonical().length() + Integer.BYTES); + } + } + + record BitmapArguments(RedisBitmapOffset first, RedisBitmapOffset last, int bit) + implements Arguments { + + BitmapArguments { + Objects.requireNonNull(first, "first offset must be non-null"); + Objects.requireNonNull(last, "last offset must be non-null"); + if (last.value() < first.value() || bit < -1 || bit > 1) { + throw new IllegalArgumentException("bitmap arguments are invalid"); + } + } + + @Override + public int encodedBytes() { + return Long.BYTES * 2 + Integer.BYTES; + } + } + + record BitmapCountArguments(RedisBitmapByteOffset first, RedisBitmapByteOffset last) + implements Arguments { + + BitmapCountArguments { + Objects.requireNonNull(first, "first byte offset must be non-null"); + Objects.requireNonNull(last, "last byte offset must be non-null"); + if (last.value() < first.value()) { + throw new IllegalArgumentException("bitmap byte range is inverted"); + } + } + + @Override + public int encodedBytes() { + return Long.BYTES * 2; + } + } + + record GeoArguments( + RedisGeoCoordinate coordinate, + Shape shape, + double firstMeters, + double secondMeters, + RedisPrimitiveLimit limit, + Sort sort) + implements Arguments { + + enum Shape { + RADIUS, + BOX + } + + enum Sort { + ASCENDING, + DESCENDING + } + + GeoArguments { + Objects.requireNonNull(coordinate, "coordinate must be non-null"); + Objects.requireNonNull(shape, "shape must be non-null"); + Objects.requireNonNull(limit, "limit must be non-null"); + Objects.requireNonNull(sort, "sort must be non-null"); + if (!Double.isFinite(firstMeters) + || firstMeters <= 0 + || firstMeters > 100_000 + || !Double.isFinite(secondMeters) + || secondMeters < 0 + || secondMeters > 100_000 + || (shape == Shape.RADIUS && secondMeters != 0) + || (shape == Shape.BOX && secondMeters == 0)) { + throw new IllegalArgumentException("geo shape exceeds descriptor bounds"); + } + } + + @Override + public int encodedBytes() { + return Double.BYTES * 4 + Integer.BYTES * 2; + } + } + + private final RedisPrimitiveDescriptor descriptor; + private final List keys; + private final Arguments arguments; + private final long startedAtNanos; + private final long budgetNanos; + private final LongSupplier ticker; + private final int encodedRequestBytes; + + RedisPrimitiveInvocation( + RedisPrimitiveCatalog owner, + RedisPrimitiveDescriptor descriptor, + List keys, + Arguments arguments, + LongSupplier ticker) { + Objects.requireNonNull(owner, "owner must be non-null"); + this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null"); + if (owner.descriptor(descriptor.id()) != descriptor) { + throw new IllegalArgumentException("primitive descriptor is not owned by catalog"); + } + this.keys = descriptor.validateKeys(keys); + this.arguments = Objects.requireNonNull(arguments, "arguments must be non-null"); + validateArguments(descriptor, this.arguments); + long bytes = arguments.encodedBytes(); + for (RedisPrimitiveKey key : this.keys) { + bytes = Math.addExact(bytes, key.encodedLength()); + } + if (bytes > descriptor.maximumEncodedBytes()) { + throw new IllegalArgumentException("primitive request exceeds descriptor byte bounds"); + } + this.encodedRequestBytes = Math.toIntExact(bytes); + this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null"); + this.startedAtNanos = ticker.getAsLong(); + this.budgetNanos = descriptor.totalDeadline().toNanos(); + } + + RedisPrimitiveDescriptor descriptor() { + return descriptor; + } + + List keys() { + return keys; + } + + Arguments arguments() { + return arguments; + } + + int encodedRequestBytes() { + return encodedRequestBytes; + } + + Duration remainingDeadline() { + long elapsed = ticker.getAsLong() - startedAtNanos; + if (elapsed < 0 || elapsed >= budgetNanos) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis primitive total deadline expired before dispatch", + null); + } + return Duration.ofNanos(budgetNanos - elapsed); + } + + private static int checkedBytes(List values) { + int total = 0; + for (RedisPrimitiveValue value : values) { + total = Math.addExact(total, value.encodedLength()); + } + return total; + } + + private static void validateArguments(RedisPrimitiveDescriptor descriptor, Arguments arguments) { + switch (descriptor.id()) { + case STRING_GET, COUNTER_READ, SET_CARDINALITY, LIST_POP, HLL_COUNT -> + require(arguments, NoArguments.class); + case STRING_SET_PX, STRING_SET_NX_PX, STRING_SET_XX_PX -> { + ExpiringWrite write = require(arguments, ExpiringWrite.class); + bounded(write.value(), descriptor.maximumValueBytes(), "value"); + } + case STRING_COMPARE_SET -> { + CompareSetArguments compare = require(arguments, CompareSetArguments.class); + if (compare.expectedValue() != null) { + bounded(compare.expectedValue(), descriptor.maximumValueBytes(), "expected value"); + } + bounded(compare.newValue(), descriptor.maximumValueBytes(), "new value"); + } + case STRING_COMPARE_DELETE -> { + AtomicArguments compare = require(arguments, AtomicArguments.class); + exactly(compare.values(), 1); + bounded(compare.values().getFirst(), descriptor.maximumValueBytes(), "expected value"); + } + case STRING_MGET -> { + MgetArguments mget = require(arguments, MgetArguments.class); + if (mget.maximumResultBytes() != descriptor.maximumResultBytes() + || mget.maximumValueBytes() != descriptor.maximumValueBytes()) { + throw new IllegalArgumentException("MGET bounds are not descriptor-owned"); + } + } + case COUNTER_INCREMENT_INITIAL_TTL -> require(arguments, CounterArguments.class); + case HASH_GET, HASH_MGET, HASH_DELETE_FIELDS -> { + BinaryArguments fields = require(arguments, BinaryArguments.class); + if (fields.values().size() > descriptor.maximumElements()) { + throw new IllegalArgumentException("hash field count exceeds descriptor bounds"); + } + fields.values().forEach(value -> bounded(value, descriptor.maximumFieldBytes(), "field")); + } + case HASH_SET_FIELDS -> { + HashAdmissionArguments hash = require(arguments, HashAdmissionArguments.class); + bounded(hash.field(), descriptor.maximumFieldBytes(), "field"); + bounded(hash.value(), descriptor.maximumValueBytes(), "value"); + validateLimit(hash.capacity(), descriptor); + } + case HASH_SCAN_PAGE, SET_SCAN_PAGE -> { + ScanPageArguments scan = require(arguments, ScanPageArguments.class); + if (scan.corruptionCeiling() > descriptor.maximumElements() + || scan.maximumResultBytes() != descriptor.maximumResultBytes()) { + throw new IllegalArgumentException("scan bounds are not descriptor-owned"); + } + } + case HASH_REVISION_CAS -> { + HashRevisionArguments revision = require(arguments, HashRevisionArguments.class); + bounded(revision.value(), descriptor.maximumValueBytes(), "value"); + } + case SET_CONTAINS, SET_REMOVE -> { + BinaryArguments members = require(arguments, BinaryArguments.class); + if (members.values().size() > descriptor.maximumElements()) { + throw new IllegalArgumentException("set member count exceeds descriptor bounds"); + } + members + .values() + .forEach(value -> bounded(value, descriptor.maximumMemberBytes(), "member")); + } + case SET_ADMIT -> { + CapacityArguments admission = require(arguments, CapacityArguments.class); + bounded(admission.value(), descriptor.maximumMemberBytes(), "member"); + validateLimit(admission.capacity(), descriptor); + } + case ZSET_ADD -> { + SortedSetAdmissionArguments admission = + require(arguments, SortedSetAdmissionArguments.class); + bounded(admission.member(), descriptor.maximumMemberBytes(), "member"); + validateLimit(admission.capacity(), descriptor); + } + case ZSET_REMOVE -> { + BinaryArguments members = require(arguments, BinaryArguments.class); + if (members.values().size() > descriptor.maximumElements()) { + throw new IllegalArgumentException("sorted-set member count exceeds descriptor bounds"); + } + members + .values() + .forEach(value -> bounded(value, descriptor.maximumMemberBytes(), "member")); + } + case ZSET_COUNT, ZSET_SCORE_PAGE -> { + ScoreRangeArguments range = require(arguments, ScoreRangeArguments.class); + validateLimit(range.limit(), descriptor); + } + case ZSET_RANK_PAGE -> { + RangeArguments range = require(arguments, RangeArguments.class); + validateLimit(range.limit(), descriptor); + } + case ZSET_TRIM_BOUNDED, LIST_TRIM_FIXED_BOUNDS -> require(arguments, AtomicArguments.class); + case LIST_ADMIT -> { + CapacityArguments admission = require(arguments, CapacityArguments.class); + bounded(admission.value(), descriptor.maximumValueBytes(), "value"); + validateLimit(admission.capacity(), descriptor); + } + case BITMAP_GET, BITMAP_SET -> { + BitmapArguments bitmap = require(arguments, BitmapArguments.class); + if (bitmap.first().maximumExclusive() != 8_388_608 + || bitmap.last().maximumExclusive() != 8_388_608) { + throw new IllegalArgumentException("bitmap offsets are not descriptor-owned"); + } + } + case BITMAP_COUNT_FIXED_RANGE -> require(arguments, BitmapCountArguments.class); + case HLL_ADD -> { + BinaryArguments elements = require(arguments, BinaryArguments.class); + if (elements.values().size() > descriptor.maximumElements()) { + throw new IllegalArgumentException("HLL element count exceeds descriptor bounds"); + } + elements.values().forEach(value -> bounded(value, descriptor.maximumValueBytes(), "value")); + } + case HLL_MERGE_SAME_SLOT -> require(arguments, NoArguments.class); + case GEO_ADD -> { + GeoAdmissionArguments admission = require(arguments, GeoAdmissionArguments.class); + bounded(admission.member(), descriptor.maximumMemberBytes(), "member"); + validateLimit(admission.capacity(), descriptor); + } + case GEO_SEARCH -> { + GeoArguments geo = require(arguments, GeoArguments.class); + validateLimit(geo.limit(), descriptor); + } + default -> throw new IllegalArgumentException("primitive operation has no argument contract"); + } + } + + private static T require(Arguments arguments, Class type) { + if (!type.isInstance(arguments)) { + throw new IllegalArgumentException("primitive arguments do not match operation descriptor"); + } + return type.cast(arguments); + } + + private static void exactly(List values, int expected) { + if (values.size() != expected) { + throw new IllegalArgumentException("primitive argument arity is invalid"); + } + } + + private static void bounded(RedisPrimitiveValue value, int maximumBytes, String kind) { + if (value.encodedLength() > maximumBytes) { + throw new IllegalArgumentException("primitive " + kind + " exceeds descriptor bounds"); + } + } + + private static void validateLimit( + RedisPrimitiveLimit limit, RedisPrimitiveDescriptor descriptor) { + if (limit.maximum() != descriptor.maximumElements() + || limit.value() > descriptor.maximumElements()) { + throw new IllegalArgumentException("primitive limit is not descriptor-owned"); + } + } + + private static boolean token(String value) { + return value.matches("[A-Za-z0-9_-]{1,64}"); + } + + private static RedisPrimitiveValue ascii(String value) { + return RedisPrimitiveValue.utf8(value, 128); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKey.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKey.java new file mode 100644 index 0000000..e0253d6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKey.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +final class RedisPrimitiveKey { + + private final String family; + private final int version; + private final String slot; + private final String identity; + private final RedisPhysicalKey physicalKey; + + private RedisPrimitiveKey( + RedisPrimitiveKeyFactory owner, + String family, + int version, + String slot, + String identity, + int maximumKeyBytes) { + if (!Objects.requireNonNull(owner, "owner must be non-null").owns(family, version)) { + throw new IllegalArgumentException("primitive key factory does not own key family"); + } + this.family = family; + this.version = version; + this.slot = Objects.requireNonNull(slot, "slot must be non-null"); + this.identity = Objects.requireNonNull(identity, "identity must be non-null"); + String encoded = "ca:primitive:" + family + ":v" + version + ":{" + slot + "}:" + identity; + byte[] keyBytes = encoded.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length > maximumKeyBytes) { + throw new IllegalArgumentException("primitive key material is outside bounds"); + } + this.physicalKey = RedisPhysicalKey.primitive(this); + } + + static RedisPrimitiveKey canonical( + RedisPrimitiveKeyFactory owner, + String family, + int version, + String slot, + String identity, + int maximumKeyBytes) { + return new RedisPrimitiveKey(owner, family, version, slot, identity, maximumKeyBytes); + } + + String family() { + return family; + } + + int version() { + return version; + } + + String slot() { + return slot; + } + + String identity() { + return identity; + } + + int encodedLength() { + return physicalKey.encodedLength(); + } + + RedisPhysicalKey physicalKey() { + return physicalKey; + } + + @Override + public String toString() { + return "RedisPrimitiveKey[family=" + family + ",version=" + version + ",redacted]"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKeyFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKeyFactory.java new file mode 100644 index 0000000..2509d3c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveKeyFactory.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; + +final class RedisPrimitiveKeyFactory { + + private final RedisPrimitiveDescriptor descriptor; + + private RedisPrimitiveKeyFactory(RedisPrimitiveDescriptor descriptor) { + this.descriptor = Objects.requireNonNull(descriptor, "descriptor must be non-null"); + } + + static RedisPrimitiveKeyFactory canonical(RedisPrimitiveCatalog catalog, RedisPrimitiveId id) { + Objects.requireNonNull(catalog, "catalog must be non-null"); + return new RedisPrimitiveKeyFactory(catalog.descriptor(id)); + } + + RedisPrimitiveKey key(String slot, String identity) { + if (slot == null + || !slot.matches("[A-Za-z0-9_-]{1,64}") + || identity == null + || !identity.matches("[A-Za-z0-9._:-]{1,256}")) { + throw new IllegalArgumentException("primitive key material is outside bounds"); + } + return RedisPrimitiveKey.canonical( + this, + descriptor.keyFamily(), + descriptor.keyVersion(), + slot, + identity, + descriptor.maximumKeyBytes()); + } + + boolean owns(String family, int version) { + return descriptor.keyFamily().equals(family) && descriptor.keyVersion() == version; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveLimit.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveLimit.java new file mode 100644 index 0000000..2296d7a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveLimit.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Positive descriptor-capped count; callers cannot request an unlimited operation. */ +record RedisPrimitiveLimit(int value, int maximum) { + + RedisPrimitiveLimit { + if (maximum < 1 || value < 1 || value > maximum) { + throw new IllegalArgumentException("primitive limit exceeds descriptor bounds"); + } + } + + static RedisPrimitiveLimit of(int value, RedisPrimitiveDescriptor descriptor) { + return new RedisPrimitiveLimit(value, descriptor.maximumElements()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveMutationResult.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveMutationResult.java new file mode 100644 index 0000000..0f90bb8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveMutationResult.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.OptionalLong; + +/** Mutation outcome keeps transport certainty separate from semantic status. */ +record RedisPrimitiveMutationResult( + Status status, Certainty certainty, OptionalLong numericDetail, String diagnosticCode) { + + enum Status { + APPLIED, + CONDITION_NOT_MET, + CAPACITY_EXCEEDED, + LIMIT_EXCEEDED, + OVERFLOW, + MISMATCH, + WRONG_TYPE, + INVALID, + CORRUPT, + UNKNOWN + } + + enum Certainty { + APPLIED, + NOT_APPLIED, + INDETERMINATE + } + + RedisPrimitiveMutationResult { + if (status == null || certainty == null || numericDetail == null) { + throw new IllegalArgumentException("primitive mutation result is invalid"); + } + diagnosticCode = diagnosticCode == null ? "" : diagnosticCode; + } + + static RedisPrimitiveMutationResult from(RedisPrimitiveReply reply) { + Status resultStatus = + switch (reply.status()) { + case APPLIED, + UPDATED, + ADMITTED, + ADDED, + SCORE_CHANGED, + POSITION_CHANGED, + SET_EXISTING, + TRIMMED, + REMOVED -> + Status.APPLIED; + case CONDITION_NOT_MET, ALREADY_PRESENT, NOT_FOUND, UNCHANGED -> Status.CONDITION_NOT_MET; + case CAPACITY_EXCEEDED, STATE_OVER_CAPACITY, TOO_EXPENSIVE -> Status.CAPACITY_EXCEEDED; + case LIMIT_EXCEEDED -> Status.LIMIT_EXCEEDED; + case OVERFLOW -> Status.OVERFLOW; + case MISMATCH -> Status.MISMATCH; + case WRONG_TYPE -> Status.WRONG_TYPE; + case INVALID, MISSING_TTL, TTL_APPLY_FAILED -> Status.INVALID; + case MALFORMED_VALUE, MALFORMED_REVISION, CORRUPT, CORRUPT_AFTER_WRITE -> Status.CORRUPT; + default -> Status.UNKNOWN; + }; + Certainty certainty = + reply.status() == RedisPrimitiveReply.Status.CORRUPT_AFTER_WRITE + ? Certainty.INDETERMINATE + : resultStatus == Status.APPLIED ? Certainty.APPLIED : Certainty.NOT_APPLIED; + return new RedisPrimitiveMutationResult( + resultStatus, certainty, reply.signedNumber(), reply.diagnosticCode()); + } + + static RedisPrimitiveMutationResult failed(RedisCommandFailureException failure) { + Certainty certainty = + failure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? Certainty.INDETERMINATE + : Certainty.NOT_APPLIED; + return new RedisPrimitiveMutationResult(Status.UNKNOWN, certainty, OptionalLong.empty(), ""); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitivePage.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitivePage.java new file mode 100644 index 0000000..fdddf4c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitivePage.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; +import java.util.Objects; + +/** Bounded maintenance page; cursor observations are never snapshot semantics. */ +final class RedisPrimitivePage { + + private final List elements; + private final RedisPrimitiveCursor nextCursor; + private final boolean complete; + private final int encodedBytes; + + private RedisPrimitivePage( + List elements, RedisPrimitiveCursor nextCursor, boolean complete, int encodedBytes) { + this.elements = elements; + this.nextCursor = nextCursor; + this.complete = complete; + this.encodedBytes = encodedBytes; + } + + static RedisPrimitivePage bounded( + RedisPrimitiveDescriptor descriptor, + List elements, + RedisPrimitiveCursor nextCursor, + int encodedBytes) { + Objects.requireNonNull(descriptor, "descriptor must be non-null"); + List safe = List.copyOf(Objects.requireNonNull(elements, "elements must be non-null")); + Objects.requireNonNull(nextCursor, "nextCursor must be non-null"); + if (safe.size() > descriptor.maximumElements() + || encodedBytes < 0 + || encodedBytes > descriptor.maximumResultBytes()) { + throw new IllegalArgumentException("primitive page exceeds descriptor bounds"); + } + boolean complete = "0".equals(nextCursor.rawCursor()); + return new RedisPrimitivePage<>(safe, nextCursor, complete, encodedBytes); + } + + List elements() { + return elements; + } + + RedisPrimitiveCursor nextCursor() { + return nextCursor; + } + + boolean complete() { + return complete; + } + + int encodedBytes() { + return encodedBytes; + } + + RedisPrimitiveCursor.ObservationSemantics observationSemantics() { + return nextCursor.observationSemantics(); + } + + RedisPrimitivePage checkedElements(Class type) { + Objects.requireNonNull(type, "page element type must be non-null"); + List checked = elements.stream().map(type::cast).toList(); + return new RedisPrimitivePage<>(checked, nextCursor, complete, encodedBytes); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcher.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcher.java new file mode 100644 index 0000000..c313e41 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcher.java @@ -0,0 +1,313 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalLong; + +/** + * Executes one primitive-owned program inside a selected runtime/router lease and parses exactly. + */ +final class RedisPrimitiveProgramDispatcher { + + private final RedisProgramCatalog programs = RedisProgramCatalog.unified(); + private final RedisStructuredCommands commands; + + RedisPrimitiveProgramDispatcher(RedisStructuredCommands commands) { + this.commands = java.util.Objects.requireNonNull(commands, "commands must be non-null"); + } + + RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + RedisProgramDescriptor program = programs.descriptor(invocation.descriptor().programId()); + if (program.id() == RedisProgramId.BOUNDED_GET_V1) { + return boundedGet(invocation, program); + } + if (!(invocation.arguments() instanceof RedisPrimitiveInvocation.ProgramArguments)) { + throw new IllegalArgumentException("atomic primitive requires closed atomic arguments"); + } + if (program.replyFieldCount() == 1) { + return valueProgram(invocation, program); + } + RedisCatalogProgramInvocation catalogInvocation = + programs.primitiveMultiInvocation( + program, + invocation, + invocation.descriptor().retrySafety() + == RedisPrimitiveDescriptor.RetrySafety.SAFE_READ); + List fields = RedisScriptRecovery.evalMulti(commands, catalogInvocation); + if (fields == null || fields.size() != 3) { + throw incompatible(program, ""); + } + String version = ascii(fields.get(0), program); + String statusText = ascii(fields.get(1), program); + if (!"V1".equals(version) || !program.statuses().contains(statusText)) { + throw incompatible(program, statusText); + } + RedisPrimitiveReply.Status status; + try { + status = RedisPrimitiveReply.Status.valueOf(statusText); + } catch (IllegalArgumentException exception) { + throw incompatible(program, statusText); + } + if (program.id() == RedisProgramId.BOUNDED_MGET_V1 && status == RedisPrimitiveReply.Status.OK) { + int expected = + ((RedisPrimitiveInvocation.MgetArguments) invocation.arguments()).requestedKeyCount(); + return RedisPrimitiveReply.bulk( + invocation.descriptor(), + status, + parsePackedElements(fields.get(2), expected, invocation.descriptor(), true, expected)); + } + if ((program.id() == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1 + || program.id() == RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1) + && status == RedisPrimitiveReply.Status.PAGE) { + RedisPrimitiveInvocation.ScanPageArguments scanArguments = + (RedisPrimitiveInvocation.ScanPageArguments) invocation.arguments(); + int maximumPackedElements = + program.id() == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1 + ? 1 + 2 * scanArguments.corruptionCeiling() + : 1 + scanArguments.corruptionCeiling(); + List packed = + parsePackedElements( + fields.get(2), -1, invocation.descriptor(), false, maximumPackedElements) + .stream() + .map(element -> element.value().orElseThrow()) + .toList(); + if (packed.isEmpty()) { + throw packedFailure(invocation.descriptor()); + } + String nextRaw = cursor(packed.getFirst(), program); + RedisPrimitiveCursor current = + ((RedisPrimitiveInvocation.ScanPageArguments) invocation.arguments()).cursor(); + RedisPrimitiveCursor next = current.advance(nextRaw); + List pageValues = packed.subList(1, packed.size()); + if (program.id() == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1) { + if ((pageValues.size() & 1) != 0) { + throw packedFailure(invocation.descriptor()); + } + List entries = new ArrayList<>(pageValues.size() / 2); + for (int index = 0; index < pageValues.size(); index += 2) { + if (pageValues.get(index).encodedLength() > invocation.descriptor().maximumFieldBytes()) { + throw packedFailure(invocation.descriptor()); + } + entries.add( + new RedisPrimitiveHashEntry(pageValues.get(index), pageValues.get(index + 1))); + } + return RedisPrimitiveReply.page( + invocation.descriptor(), + RedisPrimitivePage.bounded( + invocation.descriptor(), entries, next, fields.get(2).length)); + } + if (pageValues.stream() + .anyMatch( + value -> value.encodedLength() > invocation.descriptor().maximumMemberBytes())) { + throw packedFailure(invocation.descriptor()); + } + return RedisPrimitiveReply.page( + invocation.descriptor(), + RedisPrimitivePage.bounded( + invocation.descriptor(), pageValues, next, fields.get(2).length)); + } + String detail = ascii(fields.get(2), program); + OptionalLong number = exactNumber(program.id(), status, detail, program); + String diagnosticCode = + status == RedisPrimitiveReply.Status.INVALID ? diagnostic(detail, program) : ""; + return RedisPrimitiveReply.bounded( + invocation.descriptor(), status, List.of(), number, diagnosticCode); + } + + private RedisPrimitiveReply boundedGet( + RedisPrimitiveInvocation invocation, RedisProgramDescriptor program) { + RedisCatalogProgramInvocation catalogInvocation = + programs.primitiveValueInvocation(program, invocation, true); + try { + byte[] value = RedisScriptRecovery.evalReadOnlyValue(commands, catalogInvocation); + return value == null + ? RedisPrimitiveReply.bounded( + invocation.descriptor(), + RedisPrimitiveReply.Status.MISSING, + List.of(), + OptionalLong.empty(), + "") + : RedisPrimitiveReply.bounded( + invocation.descriptor(), + RedisPrimitiveReply.Status.PRESENT, + List.of( + RedisPrimitiveValue.copyOf(value, invocation.descriptor().maximumValueBytes())), + OptionalLong.empty(), + ""); + } catch (RedisValueTooLargeException failure) { + return RedisPrimitiveReply.bounded( + invocation.descriptor(), + RedisPrimitiveReply.Status.VALUE_TOO_LARGE, + List.of(), + OptionalLong.empty(), + ""); + } + } + + private RedisPrimitiveReply valueProgram( + RedisPrimitiveInvocation invocation, RedisProgramDescriptor program) { + RedisCatalogProgramInvocation valueInvocation = + programs.primitiveValueInvocation(program, invocation); + String foundationStatus = + ascii(RedisScriptRecovery.evalValue(commands, valueInvocation), program); + if (!program.statuses().contains(foundationStatus)) { + throw incompatible(program, foundationStatus); + } + RedisPrimitiveReply.Status mapped = + switch (foundationStatus) { + case "DELETED" -> RedisPrimitiveReply.Status.REMOVED; + case "ABSENT" -> RedisPrimitiveReply.Status.NOT_FOUND; + case "NOT_OWNER" -> RedisPrimitiveReply.Status.MISMATCH; + case "WRONG_TYPE" -> RedisPrimitiveReply.Status.WRONG_TYPE; + case "INVALID" -> RedisPrimitiveReply.Status.INVALID; + default -> throw incompatible(program, foundationStatus); + }; + return RedisPrimitiveReply.bounded( + invocation.descriptor(), mapped, List.of(), OptionalLong.empty(), ""); + } + + private static String ascii(byte[] value, RedisProgramDescriptor descriptor) { + if (value == null || value.length < 1 || value.length > descriptor.maximumReplyFieldBytes()) { + throw incompatible(descriptor, ""); + } + for (byte item : value) { + if (item < 0x20 || item > 0x7e) { + throw incompatible(descriptor, ""); + } + } + return new String(value, StandardCharsets.US_ASCII); + } + + private static OptionalLong exactNumber( + RedisProgramId id, + RedisPrimitiveReply.Status status, + String value, + RedisProgramDescriptor descriptor) { + boolean signed = + id == RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1 + && switch (status) { + case UPDATED, LIMIT_EXCEEDED, OVERFLOW, MISSING_TTL -> true; + default -> false; + }; + boolean unsigned = + switch (status) { + case ADMITTED, + SET_EXISTING, + ADDED, + SCORE_CHANGED, + POSITION_CHANGED, + UNCHANGED, + ALREADY_PRESENT, + CAPACITY_EXCEEDED, + TRIMMED, + TOO_EXPENSIVE -> + true; + default -> false; + }; + if (!signed && !unsigned) { + return OptionalLong.empty(); + } + String pattern = signed ? "-?(0|[1-9][0-9]{0,18})" : "0|[1-9][0-9]{0,18}"; + if (!value.matches(pattern) || "-0".equals(value)) { + throw incompatible(descriptor, ""); + } + try { + long parsed = Long.parseLong(value); + if (unsigned && parsed < 0) { + throw incompatible(descriptor, ""); + } + return OptionalLong.of(parsed); + } catch (NumberFormatException exception) { + throw incompatible(descriptor, ""); + } + } + + private static String diagnostic(String detail, RedisProgramDescriptor descriptor) { + if (!detail.matches("[A-Z][A-Z0-9_-]{0,31}")) { + throw incompatible(descriptor, ""); + } + return detail; + } + + private static String cursor(RedisPrimitiveValue value, RedisProgramDescriptor descriptor) { + byte[] encoded = value.copyEncoded(); + String cursor = new String(encoded, StandardCharsets.US_ASCII); + if (!cursor.matches("0|[1-9][0-9]{0,19}")) { + throw incompatible(descriptor, ""); + } + return cursor; + } + + private static List parsePackedElements( + byte[] packed, + int expectedElements, + RedisPrimitiveDescriptor descriptor, + boolean missingMarkerAllowed, + int maximumPackedElements) { + if (packed == null || packed.length > descriptor.maximumResultBytes()) { + throw packedFailure(descriptor); + } + List elements = new ArrayList<>(); + int offset = 0; + while (offset < packed.length) { + int colon = offset; + while (colon < packed.length && packed[colon] != ':') { + byte item = packed[colon]; + if ((item < '0' || item > '9') + && !(missingMarkerAllowed && colon == offset && item == '-')) { + throw packedFailure(descriptor); + } + colon++; + } + if (colon == packed.length || colon == offset) { + throw packedFailure(descriptor); + } + String lengthText = new String(packed, offset, colon - offset, StandardCharsets.US_ASCII); + if (missingMarkerAllowed && "-1".equals(lengthText)) { + elements.add(RedisPrimitiveElementResult.missing()); + offset = colon + 1; + continue; + } + if (!lengthText.matches("0|[1-9][0-9]{0,6}")) { + throw packedFailure(descriptor); + } + int length; + try { + length = Integer.parseInt(lengthText); + } catch (NumberFormatException exception) { + throw packedFailure(descriptor); + } + offset = colon + 1; + if (length < 1 + || length > descriptor.maximumValueBytes() + || offset > packed.length - length) { + throw packedFailure(descriptor); + } + elements.add( + RedisPrimitiveElementResult.present( + RedisPrimitiveValue.copyOf( + java.util.Arrays.copyOfRange(packed, offset, offset + length), + descriptor.maximumValueBytes()))); + offset += length; + if (elements.size() > maximumPackedElements) { + throw packedFailure(descriptor); + } + } + if (expectedElements >= 0 && elements.size() != expectedElements) { + throw packedFailure(descriptor); + } + return List.copyOf(elements); + } + + private static RedisProgramCompatibilityException packedFailure( + RedisPrimitiveDescriptor descriptor) { + return new RedisProgramCompatibilityException( + descriptor.programId(), ""); + } + + private static RedisProgramCompatibilityException incompatible( + RedisProgramDescriptor descriptor, String status) { + return new RedisProgramCompatibilityException(descriptor.id(), status); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveReply.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveReply.java new file mode 100644 index 0000000..7c156ef --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveReply.java @@ -0,0 +1,183 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; + +/** Bounded typed reply shared by the closed primitive runtime and structure facades. */ +final class RedisPrimitiveReply { + + enum Status { + PRESENT, + MISSING, + ABSENT_OR_WRONG_TYPE, + OK, + APPLIED, + CONDITION_NOT_MET, + UPDATED, + MISMATCH, + ADMITTED, + SET_EXISTING, + ADDED, + SCORE_CHANGED, + POSITION_CHANGED, + UNCHANGED, + ALREADY_PRESENT, + CAPACITY_EXCEEDED, + STATE_OVER_CAPACITY, + LIMIT_EXCEEDED, + OVERFLOW, + MALFORMED_VALUE, + MALFORMED_REVISION, + MISSING_TTL, + TTL_APPLY_FAILED, + WRONG_TYPE, + INVALID, + REMOVED, + TRIMMED, + NOT_FOUND, + MEMBER, + NOT_MEMBER, + COUNT, + PAGE, + TOO_LARGE, + VALUE_TOO_LARGE, + TOO_EXPENSIVE, + CORRUPT, + CORRUPT_AFTER_WRITE + } + + private final Status status; + private final List values; + private final List elements; + private final OptionalLong signedNumber; + private final String diagnosticCode; + private final RedisPrimitivePage page; + + private RedisPrimitiveReply( + Status status, + List values, + List elements, + OptionalLong signedNumber, + String diagnosticCode, + RedisPrimitivePage page) { + this.status = java.util.Objects.requireNonNull(status, "status must be non-null"); + this.values = List.copyOf(values); + this.elements = List.copyOf(elements); + this.signedNumber = + java.util.Objects.requireNonNull(signedNumber, "signedNumber must be non-null"); + this.diagnosticCode = diagnosticCode; + this.page = page; + } + + static RedisPrimitiveReply bounded( + RedisPrimitiveDescriptor descriptor, + Status status, + List values, + OptionalLong signedNumber, + String diagnosticCode) { + List safe = List.copyOf(values); + boolean pairedPage = + status == Status.PAGE + && (descriptor.id() == RedisPrimitiveId.ZSET_SCORE_PAGE + || descriptor.id() == RedisPrimitiveId.GEO_SEARCH); + if (pairedPage) { + if (signedNumber.isEmpty() + || signedNumber.getAsLong() < 0 + || signedNumber.getAsLong() > descriptor.maximumElements() + || safe.size() != Math.multiplyExact(Math.toIntExact(signedNumber.getAsLong()), 2)) { + throw new IllegalArgumentException("primitive paired reply exceeds logical element bounds"); + } + } else if (safe.size() > descriptor.maximumElements()) { + throw new IllegalArgumentException("primitive reply exceeds element bounds"); + } + long bytes = 0; + for (RedisPrimitiveValue value : safe) { + if (value.encodedLength() > descriptor.maximumValueBytes()) { + throw new IllegalArgumentException("primitive reply value exceeds bounds"); + } + bytes = Math.addExact(bytes, value.encodedLength()); + } + if (bytes > descriptor.maximumResultBytes()) { + throw new IllegalArgumentException("primitive reply exceeds aggregate byte bounds"); + } + String safeCode = diagnosticCode == null ? "" : diagnosticCode; + if (!safeCode.matches("[A-Z0-9_-]{0,32}")) { + throw new IllegalArgumentException("primitive diagnostic code is invalid"); + } + return new RedisPrimitiveReply(status, safe, List.of(), signedNumber, safeCode, null); + } + + static RedisPrimitiveReply bulk( + RedisPrimitiveDescriptor descriptor, + Status status, + List elements) { + List safe = List.copyOf(elements); + if (safe.size() > descriptor.maximumElements()) { + throw new IllegalArgumentException("primitive bulk reply exceeds element bounds"); + } + long bytes = 0; + for (RedisPrimitiveElementResult element : safe) { + if (element.value().isPresent()) { + RedisPrimitiveValue value = element.value().orElseThrow(); + if (value.encodedLength() > descriptor.maximumValueBytes()) { + throw new IllegalArgumentException("primitive bulk element exceeds value bounds"); + } + bytes = Math.addExact(bytes, value.encodedLength()); + } + } + if (bytes > descriptor.maximumResultBytes()) { + throw new IllegalArgumentException("primitive bulk reply exceeds aggregate byte bounds"); + } + return new RedisPrimitiveReply(status, List.of(), safe, OptionalLong.empty(), "", null); + } + + static RedisPrimitiveReply page( + RedisPrimitiveDescriptor descriptor, RedisPrimitivePage page) { + java.util.Objects.requireNonNull(page, "page must be non-null"); + if (page.elements().size() > descriptor.maximumElements() + || page.encodedBytes() > descriptor.maximumResultBytes()) { + throw new IllegalArgumentException("primitive page reply exceeds descriptor bounds"); + } + return new RedisPrimitiveReply( + Status.PAGE, List.of(), List.of(), OptionalLong.empty(), "", page); + } + + static RedisPrimitiveReply missing() { + return new RedisPrimitiveReply( + Status.MISSING, List.of(), List.of(), OptionalLong.empty(), "", null); + } + + static RedisPrimitiveReply applied(long affected, String diagnosticCode) { + if (affected < 0) { + throw new IllegalArgumentException("affected count must be non-negative"); + } + String safeCode = diagnosticCode == null ? "" : diagnosticCode; + return new RedisPrimitiveReply( + Status.APPLIED, List.of(), List.of(), OptionalLong.of(affected), safeCode, null); + } + + Status status() { + return status; + } + + List values() { + return values; + } + + List elements() { + return elements; + } + + OptionalLong signedNumber() { + return signedNumber; + } + + String diagnosticCode() { + return diagnosticCode; + } + + Optional> page() { + return Optional.ofNullable(page); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveScanOutcome.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveScanOutcome.java new file mode 100644 index 0000000..1b76efc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveScanOutcome.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Optional; + +/** Typed bounded SCAN outcome; non-page statuses never fabricate a cursor. */ +record RedisPrimitiveScanOutcome( + RedisPrimitiveReply.Status status, Optional> page) { + + RedisPrimitiveScanOutcome { + if (status == null + || page == null + || (status == RedisPrimitiveReply.Status.PAGE) != page.isPresent()) { + throw new IllegalArgumentException("primitive scan outcome is inconsistent"); + } + } + + static RedisPrimitiveScanOutcome from(RedisPrimitiveReply reply, Class elementType) { + Optional> page = + reply.page().map(value -> value.checkedElements(elementType)); + return new RedisPrimitiveScanOutcome<>(reply.status(), page); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSemanticClass.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSemanticClass.java new file mode 100644 index 0000000..fa860db --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSemanticClass.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +enum RedisPrimitiveSemanticClass { + EXACT(true), + BEST_EFFORT_NOT_MESSAGING(false), + NON_AUTHORITATIVE_FIXED_DOMAIN_BITMAP(false), + APPROXIMATE_NON_AUTHORITATIVE_HLL(false), + PRIVACY_SENSITIVE_NON_AUTHORITATIVE_GEO(false); + + private final boolean authoritativeCorrectnessAllowed; + + RedisPrimitiveSemanticClass(boolean authoritativeCorrectnessAllowed) { + this.authoritativeCorrectnessAllowed = authoritativeCorrectnessAllowed; + } + + boolean authoritativeCorrectnessAllowed() { + return authoritativeCorrectnessAllowed; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStructure.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStructure.java new file mode 100644 index 0000000..14d2753 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStructure.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +enum RedisPrimitiveStructure { + STRING, + COUNTER, + HASH, + SET, + SORTED_SET, + LIST, + BITMAP, + HYPERLOGLOG, + GEO +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveValue.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveValue.java new file mode 100644 index 0000000..2651a31 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveValue.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** Descriptor-bounded opaque value used only by the internal primitive catalog. */ +final class RedisPrimitiveValue { + + private final byte[] encoded; + + private RedisPrimitiveValue(byte[] encoded, int maximumBytes) { + Objects.requireNonNull(encoded, "primitive value must be non-null"); + if (maximumBytes < 1 || encoded.length < 1 || encoded.length > maximumBytes) { + throw new IllegalArgumentException("primitive value exceeds descriptor bounds"); + } + this.encoded = encoded.clone(); + } + + static RedisPrimitiveValue copyOf(byte[] encoded, int maximumBytes) { + return new RedisPrimitiveValue(encoded, maximumBytes); + } + + static RedisPrimitiveValue utf8(String value, int maximumBytes) { + Objects.requireNonNull(value, "primitive value must be non-null"); + return new RedisPrimitiveValue(value.getBytes(StandardCharsets.UTF_8), maximumBytes); + } + + int encodedLength() { + return encoded.length; + } + + byte[] copyEncoded() { + return encoded.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof RedisPrimitiveValue candidate + && Arrays.equals(encoded, candidate.encoded); + } + + @Override + public int hashCode() { + return Arrays.hashCode(encoded); + } + + @Override + public String toString() { + return "RedisPrimitiveValue[redacted]"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java index 9cd7ae7..f385e40 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalog.java @@ -7,13 +7,19 @@ import java.security.NoSuchAlgorithmException; import java.util.Collection; import java.util.EnumMap; import java.util.HexFormat; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** Closed catalog that binds typed program IDs to immutable versioned Lua resources. */ final class RedisProgramCatalog { private static final HexFormat HEX = HexFormat.of(); + private static final Set V1_RATE_STATUSES = + Set.of("ALLOWED", "DENIED", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"); + private static final Set V2_RATE_STATUSES = + Set.of("ALLOWED", "DENIED", "DEDUP_REPLAY", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"); private final Map descriptors; @@ -23,6 +29,16 @@ final class RedisProgramCatalog { static RedisProgramCatalog foundation() { Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.BOUNDED_GET_V1, + valueDescriptor( + RedisProgramId.BOUNDED_GET_V1, + 1, + 1, + 512, + 16, + 16_777_216, + Set.of("VALUE", "ABSENT", "VALUE_TOO_LARGE"))); descriptors.put( RedisProgramId.COMPARE_AND_DELETE, descriptor( @@ -48,8 +64,500 @@ final class RedisProgramCatalog { 1, 3, 512, - 1_048_576, + 16_778_272, Set.of("SET", "EXISTS", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, + descriptor( + RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, + 1, + 4, + 512, + 16_778_272, + Set.of("REPLACED", "ABSENT", "NOT_MATCHED", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.REGION_GENERATION_INIT, + descriptor( + RedisProgramId.REGION_GENERATION_INIT, + 1, + 2, + 512, + 64, + Set.of("INITIALIZED", "EXISTING", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.REGION_GENERATION_BUMP, + descriptor( + RedisProgramId.REGION_GENERATION_BUMP, + 1, + 3, + 512, + 64, + Set.of("BUMPED", "ALREADY_APPLIED", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.CACHE_REFRESH_CLAIM, + descriptor( + RedisProgramId.CACHE_REFRESH_CLAIM, + 1, + 3, + 512, + 64, + Set.of("CLAIMED", "ALREADY_OWNED", "CONTENDED", "WRONG_TYPE", "INVALID"))); + return new RedisProgramCatalog(descriptors); + } + + static RedisProgramCatalog rateLimit() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.RATE_FIXED_WINDOW, + structuredDescriptor(RedisProgramId.RATE_FIXED_WINDOW, 1, 7, 7, V1_RATE_STATUSES)); + descriptors.put( + RedisProgramId.RATE_SLIDING_COUNTER, + structuredDescriptor(RedisProgramId.RATE_SLIDING_COUNTER, 1, 7, 7, V1_RATE_STATUSES)); + descriptors.put( + RedisProgramId.RATE_TOKEN_BUCKET, + structuredDescriptor(RedisProgramId.RATE_TOKEN_BUCKET, 1, 8, 7, V1_RATE_STATUSES)); + descriptors.put( + RedisProgramId.RATE_FIXED_WINDOW_V2, + structuredDescriptor(RedisProgramId.RATE_FIXED_WINDOW_V2, 3, 11, 8, V2_RATE_STATUSES)); + descriptors.put( + RedisProgramId.RATE_SLIDING_COUNTER_V2, + structuredDescriptor(RedisProgramId.RATE_SLIDING_COUNTER_V2, 3, 11, 8, V2_RATE_STATUSES)); + descriptors.put( + RedisProgramId.RATE_TOKEN_BUCKET_V2, + structuredDescriptor(RedisProgramId.RATE_TOKEN_BUCKET_V2, 3, 12, 8, V2_RATE_STATUSES)); + return new RedisProgramCatalog(descriptors); + } + + static RedisProgramCatalog primitiveAtomic() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1, + primitiveDescriptor( + RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1, + 4, + 3, + Set.of( + "UPDATED", + "LIMIT_EXCEEDED", + "OVERFLOW", + "MALFORMED_VALUE", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1, + primitiveDescriptor( + RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1, + 4, + 3, + Set.of("UPDATED", "MISMATCH", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_SET_ADMISSION_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_SET_ADMISSION_V1, + 3, + 3, + Set.of( + "ADMITTED", + "ALREADY_PRESENT", + "CAPACITY_EXCEEDED", + "TTL_APPLY_FAILED", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_LIST_ADMISSION_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_LIST_ADMISSION_V1, + 3, + 3, + Set.of( + "ADMITTED", + "CAPACITY_EXCEEDED", + "TTL_APPLY_FAILED", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.HASH_REVISION_CAS_V1, + primitiveDescriptor( + RedisProgramId.HASH_REVISION_CAS_V1, + 5, + 3, + Set.of( + "UPDATED", + "MISMATCH", + "MALFORMED_REVISION", + "TTL_APPLY_FAILED", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1, + 4, + 3, + Set.of( + "ADMITTED", + "SET_EXISTING", + "CAPACITY_EXCEEDED", + "STATE_OVER_CAPACITY", + "TTL_APPLY_FAILED", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_ZSET_ADMISSION_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_ZSET_ADMISSION_V1, + 4, + 3, + Set.of( + "ADDED", + "SCORE_CHANGED", + "UNCHANGED", + "CAPACITY_EXCEEDED", + "STATE_OVER_CAPACITY", + "TTL_APPLY_FAILED", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.ZSET_BOUNDED_TRIM_V1, + primitiveDescriptor( + RedisProgramId.ZSET_BOUNDED_TRIM_V1, + 2, + 3, + Set.of( + "TRIMMED", + "TOO_EXPENSIVE", + "CORRUPT_AFTER_WRITE", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.GUARDED_LIST_TRIM_V1, + primitiveDescriptor( + RedisProgramId.GUARDED_LIST_TRIM_V1, + 2, + 3, + Set.of( + "TRIMMED", + "TOO_EXPENSIVE", + "CORRUPT_AFTER_WRITE", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_GEO_ADMISSION_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_GEO_ADMISSION_V1, + 5, + 3, + Set.of( + "ADDED", + "POSITION_CHANGED", + "UNCHANGED", + "CAPACITY_EXCEEDED", + "STATE_OVER_CAPACITY", + "TTL_APPLY_FAILED", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_MGET_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_MGET_V1, + 4, + 3, + 3, + 2_097_152, + Set.of("OK", "TOO_LARGE", "VALUE_TOO_LARGE", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1, + 1, + 3, + 3, + 2_097_152, + Set.of("PAGE", "TOO_LARGE", "STATE_OVER_CAPACITY", "WRONG_TYPE", "INVALID"))); + descriptors.put( + RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1, + primitiveDescriptor( + RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1, + 1, + 3, + 3, + 2_097_152, + Set.of("PAGE", "TOO_LARGE", "STATE_OVER_CAPACITY", "WRONG_TYPE", "INVALID"))); + return new RedisProgramCatalog(descriptors); + } + + static RedisProgramCatalog idempotencyV2() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.IDEMPOTENCY_CLAIM_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_CLAIM_V1, + 8, + Set.of( + "ACQUIRED", + "REPLAYED_ACQUIRE", + "TAKEN_OVER_CLAIMED", + "COMPLETED_REPLAY", + "IN_PROGRESS", + "RECOVERY_REQUIRED", + "FINGERPRINT_MISMATCH", + "OWNER_OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.IDEMPOTENCY_START_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_START_V1, + 4, + Set.of( + "STARTED", + "ALREADY_STARTED_SAME_OPERATION", + "ABSENT", + "NOT_OWNER", + "NOT_CLAIMED", + "OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.IDEMPOTENCY_RENEW_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_RENEW_V1, + 5, + Set.of( + "RENEWED", + "ALREADY_RENEWED_SAME_OPERATION", + "ABSENT", + "NOT_OWNER", + "NOT_IN_PROGRESS", + "OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.IDEMPOTENCY_COMPLETE_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_COMPLETE_V1, + 7, + Set.of( + "COMPLETED", + "ALREADY_COMPLETED_SAME_RESULT", + "RESPONSE_CONFLICT", + "ABSENT", + "NOT_OWNER", + "NOT_IN_PROGRESS", + "OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.IDEMPOTENCY_FAIL_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_FAIL_V1, + 6, + Set.of( + "MARKED_RETRYABLE", + "MARKED_ABANDONED", + "ALREADY_MARKED_SAME_OPERATION", + "ABSENT", + "NOT_OWNER", + "NOT_IN_PROGRESS", + "OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.IDEMPOTENCY_RELEASE_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_RELEASE_V1, + 4, + Set.of( + "RELEASED_BEFORE_EXECUTION", + "ALREADY_RELEASED_SAME_OPERATION", + "ABSENT", + "NOT_OWNER", + "EXECUTION_ALREADY_STARTED", + "OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, + idempotencyDescriptor( + RedisProgramId.IDEMPOTENCY_INSPECT_V1, + 4, + Set.of( + "ABSENT", + "CLAIMED_SAME_OPERATION", + "EXECUTING_SAME_OPERATION", + "COMPLETED_REPLAY", + "IN_PROGRESS_OTHER", + "FAILED_RETRYABLE", + "ABANDONED", + "FINGERPRINT_MISMATCH", + "OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + return new RedisProgramCatalog(descriptors); + } + + static RedisProgramCatalog unified() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + for (RedisProgramCatalog catalog : + List.of( + foundation(), + primitiveAtomic(), + rateLimit(), + idempotencyV2(), + efficiencyLease(), + sessionV1())) { + for (RedisProgramDescriptor descriptor : catalog.descriptors()) { + if (descriptors.put(descriptor.id(), descriptor) != null) { + throw new IllegalStateException("duplicate Redis program id in unified catalog"); + } + } + } + return new RedisProgramCatalog(descriptors); + } + + static RedisProgramCatalog efficiencyLease() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.LEASE_ACQUIRE_V1, + leaseDescriptor( + RedisProgramId.LEASE_ACQUIRE_V1, + 4, + Set.of( + "ACQUIRED", + "REPLAYED_SAME_OPERATION", + "CONTENDED", + "OWNER_OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.LEASE_INSPECT_V1, + leaseDescriptor( + RedisProgramId.LEASE_INSPECT_V1, + 3, + Set.of( + "OWNED", + "ABSENT", + "NOT_OWNER", + "OWNER_OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.LEASE_RENEW_V1, + leaseDescriptor( + RedisProgramId.LEASE_RENEW_V1, + 4, + Set.of( + "RENEWED", + "ABSENT", + "NOT_OWNER", + "OWNER_OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + descriptors.put( + RedisProgramId.LEASE_RELEASE_V1, + leaseDescriptor( + RedisProgramId.LEASE_RELEASE_V1, + 3, + Set.of( + "RELEASED", + "ALREADY_ABSENT", + "NOT_OWNER", + "OWNER_OPERATION_CONFLICT", + "STATE_INCOMPATIBLE", + "INVALID"))); + return new RedisProgramCatalog(descriptors); + } + + static RedisProgramCatalog sessionV1() { + Map descriptors = new EnumMap<>(RedisProgramId.class); + descriptors.put( + RedisProgramId.SESSION_CREATE_V1, + sessionDescriptor( + RedisProgramId.SESSION_CREATE_V1, + 2, + 7, + 1, + Set.of( + "CREATED", + "ALREADY_CREATED_SAME_OPERATION", + "EXISTS_CONFLICT", + "TOMBSTONED", + "ABSOLUTE_EXPIRED"))); + descriptors.put( + RedisProgramId.SESSION_INSPECT_V1, + sessionDescriptor( + RedisProgramId.SESSION_INSPECT_V1, + 2, + 1, + 5, + Set.of("LIVE", "TOMBSTONED", "ABSENT", "ABSOLUTE_EXPIRED"))); + descriptors.put( + RedisProgramId.SESSION_SAVE_IF_LIVE_V1, + sessionDescriptor( + RedisProgramId.SESSION_SAVE_IF_LIVE_V1, + 2, + 8, + 1, + Set.of( + "SAVED", + "ALREADY_SAVED_SAME_OPERATION", + "ABSENT", + "STALE_REVISION", + "MUTATION_CONFLICT", + "TOMBSTONED", + "ABSOLUTE_EXPIRED"))); + descriptors.put( + RedisProgramId.SESSION_TOUCH_IF_LIVE_V1, + sessionDescriptor( + RedisProgramId.SESSION_TOUCH_IF_LIVE_V1, + 2, + 6, + 1, + Set.of( + "TOUCHED", + "ALREADY_TOUCHED_SAME_OPERATION", + "TOUCH_NOT_DUE", + "ABSENT", + "STALE_REVISION", + "TOMBSTONED", + "ABSOLUTE_EXPIRED"))); + descriptors.put( + RedisProgramId.SESSION_TOMBSTONE_AND_DELETE_V1, + sessionDescriptor( + RedisProgramId.SESSION_TOMBSTONE_AND_DELETE_V1, + 2, + 3, + 1, + Set.of( + "REVOKED_AND_DELETED", + "TOMBSTONED_ABSENT", + "ALREADY_REVOKED_SAME_OPERATION", + "STALE_REVISION", + "OPERATION_CONFLICT"))); + descriptors.put( + RedisProgramId.SESSION_ROTATE_V1, + sessionDescriptor( + RedisProgramId.SESSION_ROTATE_V1, + 4, + 10, + 1, + Set.of( + "ROTATED", + "ALREADY_ROTATED_SAME_OPERATION", + "OLD_ABSENT", + "STALE_REVISION", + "OLD_TOMBSTONED", + "NEW_ID_CONFLICT", + "ABSOLUTE_EXPIRED"))); return new RedisProgramCatalog(descriptors); } @@ -65,6 +573,45 @@ final class RedisProgramCatalog { return descriptors.values(); } + RedisCatalogProgramInvocation capabilityInvocation(RedisCatalogProgramMaterial material) { + return RedisCatalogProgramInvocation.capabilityOwned( + this, + material, + Objects.requireNonNull(material.replyShape(), "replyShape must be non-null")); + } + + RedisCatalogProgramInvocation boundedGetInvocation(RedisPhysicalKey key, int maximumValueBytes) { + return RedisCatalogProgramInvocation.boundedGetOwned( + this, descriptor(RedisProgramId.BOUNDED_GET_V1), key, maximumValueBytes); + } + + RedisCatalogProgramInvocation primitiveMultiInvocation( + RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive, boolean readOnly) { + return RedisCatalogProgramInvocation.primitiveOwned( + this, + descriptor, + primitive, + readOnly + ? RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI + : RedisCatalogProgramInvocation.ReplyShape.MULTI); + } + + RedisCatalogProgramInvocation primitiveValueInvocation( + RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive) { + return primitiveValueInvocation(descriptor, primitive, false); + } + + RedisCatalogProgramInvocation primitiveValueInvocation( + RedisProgramDescriptor descriptor, RedisPrimitiveInvocation primitive, boolean readOnly) { + return RedisCatalogProgramInvocation.primitiveOwned( + this, + descriptor, + primitive, + readOnly + ? RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE + : RedisCatalogProgramInvocation.ReplyShape.VALUE); + } + private static RedisProgramDescriptor descriptor( RedisProgramId id, int keyCount, @@ -73,15 +620,1021 @@ final class RedisProgramCatalog { int maximumArgumentBytes, Set statuses) { byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = contract(id, maximumKeyBytes, maximumArgumentBytes, 1, 128); return new RedisProgramDescriptor( id, HEX.formatHex(sha256(script)), script, keyCount, argumentCount, - maximumKeyBytes, - maximumArgumentBytes, - statuses); + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + contract.resultSchema().fieldCount(), + contract.resultSchema().maximumFieldBytes(), + statuses, + contract); + } + + private static RedisProgramDescriptor structuredDescriptor( + RedisProgramId id, + int keyCount, + int argumentCount, + int replyFieldCount, + Set statuses) { + byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = contract(id, 512, 128, replyFieldCount, 32); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + keyCount, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + replyFieldCount, + 32, + statuses, + contract); + } + + private static RedisProgramDescriptor primitiveDescriptor( + RedisProgramId id, int argumentCount, int replyFieldCount, Set statuses) { + byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = contract(id, 512, 1_048_576, replyFieldCount, 128); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + 1, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + replyFieldCount, + 128, + statuses, + contract); + } + + private static RedisProgramDescriptor primitiveDescriptor( + RedisProgramId id, + int keyCount, + int argumentCount, + int replyFieldCount, + int maximumReplyFieldBytes, + Set statuses) { + byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = + contract(id, 512, 1_048_576, replyFieldCount, maximumReplyFieldBytes); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + keyCount, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + replyFieldCount, + maximumReplyFieldBytes, + statuses, + contract); + } + + private static RedisProgramDescriptor valueDescriptor( + RedisProgramId id, + int keyCount, + int argumentCount, + int maximumKeyBytes, + int maximumArgumentBytes, + int maximumReplyFieldBytes, + Set statuses) { + byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = + contract(id, maximumKeyBytes, maximumArgumentBytes, 1, maximumReplyFieldBytes); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + keyCount, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + 1, + maximumReplyFieldBytes, + statuses, + contract); + } + + private static RedisProgramDescriptor idempotencyDescriptor( + RedisProgramId id, int argumentCount, Set statuses) { + byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = contract(id, 512, 12_000, 6, 12_000); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + 1, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + 6, + 12_000, + statuses, + contract); + } + + private static RedisProgramContract contract( + RedisProgramId id, + int maximumKeyBytes, + int maximumArgumentBytes, + int replyFieldCount, + int maximumReplyFieldBytes) { + List keyNames = keyNames(id); + List argumentNames = argumentNames(id); + return new RedisProgramContract( + semanticVersion(id), + libraryName(id), + "ca_" + id.externalId().replace('-', '_'), + inputs(keyNames, maximumKeyBytes, "resource"), + inputs(argumentNames, maximumArgumentBytes, ""), + new RedisProgramContract.ResultSchema( + resultSchemaVersion(id), replyFieldCount, maximumReplyFieldBytes, resultFields(id)), + slotRule(id), + stateBound(id), + ttlBound(id), + firstWriteValidation(id), + complexity(id), + maximumIterations(id), + stateGrowth(id), + clock(id), + "7.2", + retrySafety(id), + timeoutCertainty(id), + aclCommands(id)); + } + + private static List inputs( + List names, int maximumBytes, String slotGroup) { + java.util.ArrayList inputs = + new java.util.ArrayList<>(names.size()); + for (int index = 0; index < names.size(); index++) { + inputs.add( + new RedisProgramContract.Input( + index + 1, names.get(index), "opaque-bytes", maximumBytes, slotGroup)); + } + return List.copyOf(inputs); + } + + private static List keyNames(RedisProgramId id) { + return switch (id) { + case BOUNDED_GET_V1 -> List.of("entryKey"); + case COMPARE_AND_DELETE, COMPARE_AND_EXPIRE -> List.of("ownerKey"); + case SET_IF_ABSENT_WITH_TTL, REPLACE_IF_OBSERVED_WITH_TTL -> List.of("entryKey"); + case REGION_GENERATION_INIT, REGION_GENERATION_BUMP -> List.of("generationKey"); + case CACHE_REFRESH_CLAIM -> List.of("refreshLeaseKey"); + case INCREMENT_WITH_INITIAL_TTL_V1 -> List.of("counterKey"); + case COMPARE_AND_SET_WITH_TTL_V1 -> List.of("valueKey"); + case BOUNDED_SET_ADMISSION_V1 -> List.of("setKey"); + case BOUNDED_LIST_ADMISSION_V1 -> List.of("listKey"); + case HASH_REVISION_CAS_V1 -> List.of("hashKey"); + case BOUNDED_HASH_FIELD_ADMISSION_V1 -> List.of("hashKey"); + case BOUNDED_ZSET_ADMISSION_V1, ZSET_BOUNDED_TRIM_V1 -> List.of("zsetKey"); + case GUARDED_LIST_TRIM_V1 -> List.of("listKey"); + case BOUNDED_GEO_ADMISSION_V1 -> List.of("geoKey"); + case BOUNDED_MGET_V1 -> List.of("valueKey1", "valueKey2", "valueKey3", "valueKey4"); + case BOUNDED_HASH_SCAN_PAGE_V1 -> List.of("hashKey"); + case BOUNDED_SET_SCAN_PAGE_V1 -> List.of("setKey"); + case RATE_FIXED_WINDOW, RATE_SLIDING_COUNTER, RATE_TOKEN_BUCKET -> List.of("stateKey"); + case RATE_FIXED_WINDOW_V2, RATE_SLIDING_COUNTER_V2, RATE_TOKEN_BUCKET_V2 -> + List.of("stateKey", "dedupHashKey", "dedupOrderKey"); + case IDEMPOTENCY_CLAIM_V1, + IDEMPOTENCY_START_V1, + IDEMPOTENCY_RENEW_V1, + IDEMPOTENCY_COMPLETE_V1, + IDEMPOTENCY_FAIL_V1, + IDEMPOTENCY_RELEASE_V1, + IDEMPOTENCY_INSPECT_V1 -> + List.of("recordKey"); + case LEASE_ACQUIRE_V1, LEASE_INSPECT_V1, LEASE_RENEW_V1, LEASE_RELEASE_V1 -> + List.of("leaseKey"); + case SESSION_CREATE_V1, + SESSION_INSPECT_V1, + SESSION_SAVE_IF_LIVE_V1, + SESSION_TOUCH_IF_LIVE_V1, + SESSION_TOMBSTONE_AND_DELETE_V1 -> + List.of("liveSessionKey", "tombstoneKey"); + case SESSION_ROTATE_V1 -> + List.of("oldLiveSessionKey", "oldTombstoneKey", "newLiveSessionKey", "newTombstoneKey"); + }; + } + + private static List argumentNames(RedisProgramId id) { + return switch (id) { + case BOUNDED_GET_V1 -> List.of("maximumReadableBytes"); + case COMPARE_AND_DELETE -> List.of("expectedOwner"); + case COMPARE_AND_EXPIRE -> List.of("expectedOwner", "ttlMillis"); + case SET_IF_ABSENT_WITH_TTL -> List.of("value", "ttlMillis", "operationId"); + case REPLACE_IF_OBSERVED_WITH_TTL -> + List.of("observedDigest", "newEnvelope", "ttlMillis", "operationId"); + case REGION_GENERATION_INIT -> List.of("generationId", "ttlMillis"); + case REGION_GENERATION_BUMP -> List.of("generationId", "operationId", "ttlMillis"); + case CACHE_REFRESH_CLAIM -> List.of("ownerId", "operationId", "ttlMillis"); + case INCREMENT_WITH_INITIAL_TTL_V1 -> List.of("delta", "minimum", "maximum", "ttlMillis"); + case COMPARE_AND_SET_WITH_TTL_V1 -> + List.of("expectedKind", "expectedValue", "newValue", "ttlMillis"); + case BOUNDED_SET_ADMISSION_V1 -> List.of("member", "capacity", "ttlMillis"); + case BOUNDED_LIST_ADMISSION_V1 -> List.of("value", "capacity", "ttlMillis"); + case HASH_REVISION_CAS_V1 -> + List.of("expectedKind", "expectedRevision", "newRevision", "value", "ttlMillis"); + case BOUNDED_HASH_FIELD_ADMISSION_V1 -> List.of("field", "value", "capacity", "ttlMillis"); + case BOUNDED_ZSET_ADMISSION_V1 -> List.of("member", "score", "capacity", "ttlMillis"); + case ZSET_BOUNDED_TRIM_V1 -> List.of("inclusiveCutoffScore", "maximumRemovals"); + case GUARDED_LIST_TRIM_V1 -> List.of("retainCount", "maximumRemovals"); + case BOUNDED_GEO_ADMISSION_V1 -> + List.of("member", "longitude", "latitude", "capacity", "ttlMillis"); + case BOUNDED_MGET_V1 -> + List.of("requestedKeyCount", "maximumResultBytes", "maximumValueBytes"); + case BOUNDED_HASH_SCAN_PAGE_V1, BOUNDED_SET_SCAN_PAGE_V1 -> + List.of("cursor", "corruptionCeiling", "maximumResultBytes"); + case RATE_FIXED_WINDOW, RATE_SLIDING_COUNTER -> + List.of( + "schemaVersion", + "policyRevision", + "limit", + "cost", + "windowMillis", + "cleanupGraceMillis", + "maximumClockRegressionMillis"); + case RATE_TOKEN_BUCKET -> + List.of( + "schemaVersion", + "policyRevision", + "capacityScaled", + "refillTokensScaled", + "refillPeriodMillis", + "costScaled", + "cleanupGraceMillis", + "maximumClockRegressionMillis"); + case RATE_FIXED_WINDOW_V2, RATE_SLIDING_COUNTER_V2 -> + List.of( + "schemaVersion", + "policyRevision", + "limit", + "cost", + "windowMillis", + "cleanupGraceMillis", + "maximumClockRegressionMillis", + "evaluationId", + "dedupTtlMillis", + "maximumDedupEntries", + "maximumDedupBytes"); + case RATE_TOKEN_BUCKET_V2 -> + List.of( + "schemaVersion", + "policyRevision", + "capacityScaled", + "refillTokensScaled", + "refillPeriodMillis", + "costScaled", + "cleanupGraceMillis", + "maximumClockRegressionMillis", + "evaluationId", + "dedupTtlMillis", + "maximumDedupEntries", + "maximumDedupBytes"); + case IDEMPOTENCY_CLAIM_V1 -> + List.of( + "schemaVersion", + "fingerprint", + "ownerToken", + "operationId", + "processingTtlMillis", + "recordTtlMillis", + "responseCodecId", + "policyRevision"); + case IDEMPOTENCY_START_V1 -> List.of("schemaVersion", "ownerToken", "attempt", "operationId"); + case IDEMPOTENCY_RENEW_V1 -> + List.of("schemaVersion", "ownerToken", "attempt", "processingTtlMillis", "operationId"); + case IDEMPOTENCY_COMPLETE_V1 -> + List.of( + "schemaVersion", + "ownerToken", + "attempt", + "responsePayload", + "responseDigest", + "replayTtlMillis", + "operationId"); + case IDEMPOTENCY_FAIL_V1 -> + List.of( + "schemaVersion", + "ownerToken", + "attempt", + "failureDisposition", + "retentionMillis", + "operationId"); + case IDEMPOTENCY_RELEASE_V1 -> + List.of("schemaVersion", "ownerToken", "attempt", "operationId"); + case IDEMPOTENCY_INSPECT_V1 -> + List.of("schemaVersion", "fingerprint", "ownerToken", "operationId"); + case LEASE_ACQUIRE_V1, LEASE_RENEW_V1 -> + List.of("schemaVersion", "ownerToken", "operationId", "ttlMillis"); + case LEASE_INSPECT_V1, LEASE_RELEASE_V1 -> + List.of("schemaVersion", "ownerToken", "operationId"); + case SESSION_CREATE_V1 -> + List.of( + "payloadBase64", + "newRevision", + "absoluteExpiresAtMillis", + "lastAccessedAtMillis", + "idleTimeoutMillis", + "operationId", + "payloadSha256"); + case SESSION_INSPECT_V1 -> List.of("clientNowMillis"); + case SESSION_SAVE_IF_LIVE_V1 -> + List.of( + "payloadBase64", + "expectedRevision", + "newRevision", + "absoluteExpiresAtMillis", + "lastAccessedAtMillis", + "idleTimeoutMillis", + "operationId", + "payloadSha256"); + case SESSION_TOUCH_IF_LIVE_V1 -> + List.of( + "expectedRevision", + "requestedNowMillis", + "absoluteExpiresAtMillis", + "idleTimeoutMillis", + "touchIntervalMillis", + "operationId"); + case SESSION_TOMBSTONE_AND_DELETE_V1 -> + List.of("expectedRevision", "tombstoneTtlMillis", "operationId"); + case SESSION_ROTATE_V1 -> + List.of( + "payloadBase64", + "expectedRevision", + "newRevision", + "absoluteExpiresAtMillis", + "lastAccessedAtMillis", + "idleTimeoutMillis", + "tombstoneTtlMillis", + "operationId", + "payloadSha256", + "newIdDigest"); + }; + } + + private static List resultFields(RedisProgramId id) { + if (isIdempotency(id)) { + return List.of( + "status", + "attempt", + "expiresAtMillis", + "responsePayload", + "responseDigest", + "operationId"); + } + if (isRateV2(id)) { + return List.of( + "status", + "decision", + "serverNowMillis", + "effectiveNowMillis", + "limit", + "remaining", + "retryAfterMillis", + "resetAtMillis"); + } + if (isRate(id)) { + return List.of( + "status", + "serverNowMillis", + "effectiveNowMillis", + "limit", + "remaining", + "retryAfterMillis", + "resetAtMillis"); + } + if (isLease(id)) { + return List.of( + "status", + "remainingMillis", + "serverNowMillis", + "expiresAtMillis", + "stateRevision", + "operationId"); + } + if (id == RedisProgramId.SESSION_INSPECT_V1) { + return List.of( + "status", "payloadBase64", "revision", "absoluteExpiresAtMillis", "lastAccessedAtMillis"); + } + if (id == RedisProgramId.BOUNDED_GET_V1) { + return List.of("value"); + } + if (id == RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1) { + return List.of("version", "status", "value"); + } + if (isPrimitiveAtomic(id)) { + return List.of( + "version", + "status", + switch (id) { + case COMPARE_AND_SET_WITH_TTL_V1 -> "detail"; + case BOUNDED_SET_ADMISSION_V1, BOUNDED_LIST_ADMISSION_V1 -> "cardinality"; + case HASH_REVISION_CAS_V1 -> "revision"; + case BOUNDED_HASH_FIELD_ADMISSION_V1, + BOUNDED_ZSET_ADMISSION_V1, + ZSET_BOUNDED_TRIM_V1, + GUARDED_LIST_TRIM_V1, + BOUNDED_GEO_ADMISSION_V1, + BOUNDED_MGET_V1, + BOUNDED_HASH_SCAN_PAGE_V1, + BOUNDED_SET_SCAN_PAGE_V1 -> + "detail"; + default -> throw new IllegalStateException("unhandled primitive result"); + }); + } + return List.of("status"); + } + + private static RedisProgramContract.StateBound stateBound(RedisProgramId id) { + if (isIdempotency(id)) { + return new RedisProgramContract.StateBound("hash", 12_000, 32); + } + if (isRateV2(id)) { + return new RedisProgramContract.StateBound("hash-and-zset", 262_144, 1024); + } + if (isRate(id)) { + return new RedisProgramContract.StateBound("hash", 4096, 16); + } + if (isLease(id)) { + return new RedisProgramContract.StateBound("hash", 1024, 5); + } + if (isSession(id)) { + return new RedisProgramContract.StateBound("live-and-tombstone-hashes", 1_400_000, 10); + } + if (isPrimitiveAtomic(id)) { + return switch (id) { + case INCREMENT_WITH_INITIAL_TTL_V1 -> + new RedisProgramContract.StateBound("string-counter", 32, 1); + case COMPARE_AND_SET_WITH_TTL_V1 -> + new RedisProgramContract.StateBound("string", 1_048_576, 1); + case BOUNDED_SET_ADMISSION_V1 -> + new RedisProgramContract.StateBound("set", 1_048_576, 1024); + case BOUNDED_LIST_ADMISSION_V1 -> + new RedisProgramContract.StateBound("list", 1_048_576, 1024); + case HASH_REVISION_CAS_V1 -> new RedisProgramContract.StateBound("hash", 1_048_576, 2); + case BOUNDED_HASH_FIELD_ADMISSION_V1 -> + new RedisProgramContract.StateBound("hash", 1_048_576, 1024); + case BOUNDED_ZSET_ADMISSION_V1, ZSET_BOUNDED_TRIM_V1 -> + new RedisProgramContract.StateBound("zset", 1_048_576, 1024); + case GUARDED_LIST_TRIM_V1 -> new RedisProgramContract.StateBound("list", 1_048_576, 1024); + case BOUNDED_GEO_ADMISSION_V1 -> + new RedisProgramContract.StateBound("geo-zset", 1_048_576, 1024); + case BOUNDED_MGET_V1 -> + new RedisProgramContract.StateBound("four-bounded-strings", 2_097_152, 4); + case BOUNDED_HASH_SCAN_PAGE_V1 -> + new RedisProgramContract.StateBound("hash", 2_097_152, 1024); + case BOUNDED_SET_SCAN_PAGE_V1 -> + new RedisProgramContract.StateBound("set", 2_097_152, 1024); + default -> throw new IllegalStateException("unhandled primitive state"); + }; + } + return switch (id) { + case BOUNDED_GET_V1 -> new RedisProgramContract.StateBound("string", 16_777_216, 1); + case SET_IF_ABSENT_WITH_TTL, REPLACE_IF_OBSERVED_WITH_TTL -> + new RedisProgramContract.StateBound("string", 16_777_216, 1); + case REGION_GENERATION_INIT, REGION_GENERATION_BUMP, CACHE_REFRESH_CLAIM -> + new RedisProgramContract.StateBound("string", 129, 1); + default -> new RedisProgramContract.StateBound("string", 128, 1); + }; + } + + private static RedisProgramContract.TtlBound ttlBound(RedisProgramId id) { + if (isIdempotency(id)) { + return new RedisProgramContract.TtlBound("BOUNDED_REQUIRED", 1, 2_592_000_000L); + } + if (isRate(id)) { + return new RedisProgramContract.TtlBound("DERIVED_BOUNDED", 1, 172_800_000L); + } + if (isLease(id)) { + return switch (id) { + case LEASE_ACQUIRE_V1, LEASE_RENEW_V1 -> + new RedisProgramContract.TtlBound("BOUNDED_REQUIRED", 1, 86_400_000L); + case LEASE_INSPECT_V1 -> new RedisProgramContract.TtlBound("READ_ONLY", 0, 86_400_000L); + case LEASE_RELEASE_V1 -> + new RedisProgramContract.TtlBound("DELETE_OR_PRESERVE", 0, 86_400_000L); + default -> throw new IllegalStateException("unhandled Redis lease TTL contract"); + }; + } + if (isSession(id)) { + return switch (id) { + case SESSION_INSPECT_V1 -> + new RedisProgramContract.TtlBound("READ_ONLY", 0, 2_592_000_000L); + case SESSION_TOMBSTONE_AND_DELETE_V1 -> + new RedisProgramContract.TtlBound("BOUNDED_REQUIRED", 1, 2_592_000_000L); + default -> new RedisProgramContract.TtlBound("DERIVED_AND_BOUNDED", 1, 2_592_000_000L); + }; + } + if (id == RedisProgramId.BOUNDED_MGET_V1 + || id == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1 + || id == RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1) { + return new RedisProgramContract.TtlBound("READ_ONLY", 0, 2_678_400_000L); + } + if (isPrimitiveAtomic(id)) { + return new RedisProgramContract.TtlBound("BOUNDED_REQUIRED", 1, 2_678_400_000L); + } + return switch (id) { + case BOUNDED_GET_V1 -> new RedisProgramContract.TtlBound("READ_ONLY", 0, 2_678_400_000L); + case COMPARE_AND_DELETE -> + new RedisProgramContract.TtlBound("DELETE_OR_PRESERVE", 0, 2_678_400_000L); + case REGION_GENERATION_INIT, REGION_GENERATION_BUMP -> + new RedisProgramContract.TtlBound("OPTIONAL_PERSISTENT", 0, 2_678_400_000L); + case CACHE_REFRESH_CLAIM -> + new RedisProgramContract.TtlBound("BOUNDED_REQUIRED", 1, 300_000L); + default -> new RedisProgramContract.TtlBound("BOUNDED_REQUIRED", 1, 2_678_400_000L); + }; + } + + private static List firstWriteValidation(RedisProgramId id) { + if (isIdempotency(id)) { + return List.of( + "key-count", + "argument-count", + "key-type", + "stored-schema", + "operation-token", + "numeric-and-ttl-bounds"); + } + if (isRate(id)) { + return List.of( + "key-count", + "argument-count", + "all-key-types", + "policy-revision", + "numeric-and-ttl-bounds", + "dedup-bounds-when-present"); + } + if (isLease(id)) { + return List.of( + "key-count", + "argument-count", + "key-type", + "stored-schema", + "owner-and-operation-token", + "ttl-bound-when-present"); + } + if (isSession(id)) { + return List.of( + "key-count", + "argument-count", + "same-session-slot", + "revision-and-time-bounds", + "payload-and-digest-bounds", + "operation-token-before-mutation"); + } + if (isPrimitiveAtomic(id)) { + return List.of( + "key-count", + "argument-count", + "key-type", + "canonical-numeric-and-byte-bounds", + "existing-ttl-bound", + "acl-preflight-for-every-post-validation-command"); + } + if (id == RedisProgramId.BOUNDED_GET_V1) { + return List.of("key-count", "argument-count", "key-type", "maximum-readable-value-bound"); + } + return List.of("key-count", "argument-count", "key-type", "value-and-ttl-bounds"); + } + + private static String semanticVersion(RedisProgramId id) { + return isRateV2(id) ? "2.0.0" : "1.0.0"; + } + + private static int resultSchemaVersion(RedisProgramId id) { + return isRateV2(id) ? 2 : 1; + } + + private static String libraryName(RedisProgramId id) { + if (isIdempotency(id)) { + return "ca_idempotency_v1"; + } + if (isRateV2(id)) { + return "ca_rate_v2"; + } + if (isRate(id)) { + return "ca_rate_v1"; + } + if (isLease(id)) { + return "ca_efficiency_lease_v1"; + } + if (isSession(id)) { + return "ca_session_v1"; + } + if (isPrimitiveAtomic(id)) { + return "ca_primitive_atomic_v1"; + } + return switch (id) { + case REGION_GENERATION_INIT, + REGION_GENERATION_BUMP, + CACHE_REFRESH_CLAIM, + REPLACE_IF_OBSERVED_WITH_TTL -> + "ca_cache_v1"; + default -> "ca_primitive_v1"; + }; + } + + private static String complexity(RedisProgramId id) { + if (isRateV2(id)) { + return "O(log N), N<=1024"; + } + if (id == RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL) { + return "O(N), N<=32"; + } + if (id == RedisProgramId.BOUNDED_MGET_V1 + || id == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1 + || id == RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1) { + return "O(N), N<=1024 and encoded bytes<=2097152"; + } + if (id == RedisProgramId.ZSET_BOUNDED_TRIM_V1 || id == RedisProgramId.GUARDED_LIST_TRIM_V1) { + return "O(N), removals<=1024"; + } + if (isPrimitiveAtomic(id)) { + return "O(1), capacity<=1024"; + } + return "O(1)"; + } + + private static int maximumIterations(RedisProgramId id) { + if (isRateV2(id)) { + return 1024; + } + if (id == RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL) { + return 32; + } + return switch (id) { + case BOUNDED_MGET_V1 -> 4; + case BOUNDED_HASH_SCAN_PAGE_V1, + BOUNDED_SET_SCAN_PAGE_V1, + ZSET_BOUNDED_TRIM_V1, + GUARDED_LIST_TRIM_V1 -> + 1024; + default -> 0; + }; + } + + private static String stateGrowth(RedisProgramId id) { + if (isRateV2(id)) { + return "bounded-dedup-entries<=1024-and-bytes<=262144"; + } + if (isPrimitiveAtomic(id)) { + return switch (id) { + case BOUNDED_SET_ADMISSION_V1, BOUNDED_LIST_ADMISSION_V1 -> "bounded-cardinality<=1024"; + case BOUNDED_HASH_FIELD_ADMISSION_V1 -> "bounded-hash-fields<=1024"; + case BOUNDED_ZSET_ADMISSION_V1, ZSET_BOUNDED_TRIM_V1 -> "bounded-zset-cardinality<=1024"; + case GUARDED_LIST_TRIM_V1 -> "bounded-list-cardinality<=1024"; + case BOUNDED_GEO_ADMISSION_V1 -> "bounded-geo-cardinality<=1024"; + case BOUNDED_MGET_V1, BOUNDED_HASH_SCAN_PAGE_V1, BOUNDED_SET_SCAN_PAGE_V1 -> + "read-only-no-growth"; + case HASH_REVISION_CAS_V1 -> "fixed-hash-fields=2"; + default -> "single-bounded-value"; + }; + } + if (isIdempotency(id)) { + return "fixed-hash-fields<=32"; + } + if (isLease(id)) { + return "fixed-hash-fields<=5"; + } + if (isSession(id)) { + return "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields"; + } + if (isRate(id)) { + return "fixed-hash-fields<=16"; + } + return "single-bounded-value"; + } + + private static String clock(RedisProgramId id) { + if (id == RedisProgramId.SESSION_INSPECT_V1) { + return "CLIENT_SUPPLIED_TIME"; + } + if (id == RedisProgramId.SESSION_TOMBSTONE_AND_DELETE_V1) { + return "NONE"; + } + return isRate(id) || isIdempotency(id) || isLease(id) || isSession(id) + ? "REDIS_SERVER_TIME" + : "NONE"; + } + + private static String retrySafety(RedisProgramId id) { + if (isIdempotency(id)) { + return "INSPECT_BY_OPERATION_ID"; + } + if (isRateV2(id)) { + return "REPLAYABLE_WITH_EVALUATION_ID"; + } + if (isRate(id)) { + return "NOT_RETRY_SAFE_WITHOUT_EVALUATION_ID"; + } + if (isLease(id)) { + return id == RedisProgramId.LEASE_INSPECT_V1 + ? "READ_ONLY_RETRY_SAFE" + : "INSPECT_BY_OPERATION_ID"; + } + if (id == RedisProgramId.BOUNDED_GET_V1) { + return "READ_ONLY_RETRY_SAFE"; + } + if (id == RedisProgramId.SESSION_INSPECT_V1) { + return "READ_ONLY_RETRY_SAFE"; + } + if (id == RedisProgramId.BOUNDED_MGET_V1 + || id == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1 + || id == RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1) { + return "READ_ONLY_RETRY_SAFE"; + } + if (isSession(id)) { + return "REPLAYABLE_WITH_OPERATION_ID"; + } + if (isPrimitiveAtomic(id)) { + return "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS"; + } + return switch (id) { + case COMPARE_AND_DELETE -> "REPEAT_DESIRED_ABSENT"; + case COMPARE_AND_EXPIRE -> "INSPECT_OWNER_BEFORE_REPEAT"; + case SET_IF_ABSENT_WITH_TTL, + REGION_GENERATION_INIT, + REGION_GENERATION_BUMP, + CACHE_REFRESH_CLAIM -> + "OPERATION_TOKEN_REPLAYABLE"; + case REPLACE_IF_OBSERVED_WITH_TTL -> "INSPECT_ENVELOPE_DIGEST"; + default -> throw new IllegalStateException("unhandled Redis retry contract"); + }; + } + + private static String timeoutCertainty(RedisProgramId id) { + if (isRateV2(id)) { + return "REPLAYABLE_WITH_EVALUATION_ID"; + } + if (id == RedisProgramId.BOUNDED_GET_V1) { + return "READ_ONLY"; + } + if (id == RedisProgramId.LEASE_INSPECT_V1) { + return "READ_ONLY"; + } + if (id == RedisProgramId.SESSION_INSPECT_V1) { + return "READ_ONLY_RETRY_SAFE"; + } + if (id == RedisProgramId.BOUNDED_MGET_V1 + || id == RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1 + || id == RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1) { + return "READ_ONLY"; + } + if (isSession(id)) { + return "REPLAYABLE_WITH_OPERATION_ID"; + } + return "INDETERMINATE"; + } + + private static Set aclCommands(RedisProgramId id) { + return switch (id) { + case BOUNDED_GET_V1 -> Set.of("GETRANGE", "EXISTS"); + case COMPARE_AND_DELETE -> Set.of("TYPE", "GET", "DEL"); + case COMPARE_AND_EXPIRE -> Set.of("TYPE", "GET", "PEXPIRE"); + case SET_IF_ABSENT_WITH_TTL -> Set.of("TYPE", "SET"); + case REPLACE_IF_OBSERVED_WITH_TTL -> Set.of("TYPE", "GETRANGE", "SET"); + case REGION_GENERATION_INIT, REGION_GENERATION_BUMP -> + Set.of("TYPE", "GET", "SET", "PERSIST", "PEXPIRE"); + case CACHE_REFRESH_CLAIM -> Set.of("TYPE", "GET", "SET"); + case INCREMENT_WITH_INITIAL_TTL_V1 -> + Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "GET", "PTTL", "SET", "INCRBY"); + case COMPARE_AND_SET_WITH_TTL_V1 -> Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "GET", "SET"); + case BOUNDED_SET_ADMISSION_V1 -> + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "SISMEMBER", + "SCARD", + "SADD", + "PEXPIRE", + "DEL"); + case BOUNDED_LIST_ADMISSION_V1 -> + Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "LLEN", "RPUSH", "PEXPIRE", "DEL"); + case HASH_REVISION_CAS_V1 -> + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "HLEN", + "HEXISTS", + "HGET", + "HSET", + "PEXPIRE", + "DEL"); + case BOUNDED_HASH_FIELD_ADMISSION_V1 -> + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "HLEN", + "HEXISTS", + "HSET", + "PEXPIRE", + "DEL"); + case BOUNDED_ZSET_ADMISSION_V1 -> + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "ZCARD", + "ZSCORE", + "ZADD", + "PEXPIRE", + "DEL"); + case ZSET_BOUNDED_TRIM_V1 -> + Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "ZCOUNT", "ZREMRANGEBYSCORE"); + case GUARDED_LIST_TRIM_V1 -> + Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "LLEN", "LTRIM"); + case BOUNDED_GEO_ADMISSION_V1 -> + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "ZCARD", + "ZSCORE", + "GEOADD", + "PEXPIRE", + "DEL"); + case BOUNDED_MGET_V1 -> Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "STRLEN", "GET"); + case BOUNDED_HASH_SCAN_PAGE_V1 -> Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "HLEN", "HSCAN"); + case BOUNDED_SET_SCAN_PAGE_V1 -> Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "SCARD", "SSCAN"); + case RATE_FIXED_WINDOW, RATE_SLIDING_COUNTER, RATE_TOKEN_BUCKET -> + Set.of("TYPE", "TIME", "HMGET", "HSET", "PEXPIRE"); + case RATE_FIXED_WINDOW_V2, RATE_SLIDING_COUNTER_V2, RATE_TOKEN_BUCKET_V2 -> + Set.of( + "TYPE", + "TIME", + "HMGET", + "HGET", + "HSET", + "HDEL", + "HLEN", + "PEXPIRE", + "ZSCORE", + "ZADD", + "ZREM", + "ZCARD", + "ZRANGEBYSCORE", + "ZPOPMIN"); + case IDEMPOTENCY_CLAIM_V1, + IDEMPOTENCY_COMPLETE_V1, + IDEMPOTENCY_FAIL_V1, + IDEMPOTENCY_RELEASE_V1 -> + Set.of("TYPE", "TIME", "HMGET", "HSET", "HDEL", "PEXPIRE"); + case IDEMPOTENCY_RENEW_V1 -> Set.of("TYPE", "TIME", "HMGET", "HSET", "PEXPIRE"); + case IDEMPOTENCY_START_V1 -> Set.of("TYPE", "TIME", "HMGET", "HSET"); + case IDEMPOTENCY_INSPECT_V1 -> Set.of("TYPE", "TIME", "HMGET"); + case LEASE_ACQUIRE_V1 -> Set.of("TYPE", "TIME", "HSET", "PEXPIRE", "HMGET", "PTTL", "DEL"); + case LEASE_INSPECT_V1 -> Set.of("TYPE", "TIME", "HMGET", "PTTL"); + case LEASE_RENEW_V1 -> Set.of("TYPE", "TIME", "HMGET", "PTTL", "HSET", "PEXPIRE"); + case LEASE_RELEASE_V1 -> Set.of("TYPE", "TIME", "HMGET", "PTTL", "DEL"); + case SESSION_CREATE_V1 -> Set.of("TIME", "EXISTS", "HMGET", "HSET", "PEXPIRE"); + case SESSION_INSPECT_V1 -> Set.of("EXISTS", "HMGET", "DEL"); + case SESSION_SAVE_IF_LIVE_V1, SESSION_TOUCH_IF_LIVE_V1 -> + Set.of("TIME", "EXISTS", "HMGET", "DEL", "HSET", "PEXPIRE"); + case SESSION_TOMBSTONE_AND_DELETE_V1 -> Set.of("HGET", "HSET", "PEXPIRE", "DEL"); + case SESSION_ROTATE_V1 -> Set.of("TIME", "HGET", "EXISTS", "HMGET", "HSET", "PEXPIRE", "DEL"); + }; + } + + private static boolean isRate(RedisProgramId id) { + return switch (id) { + case RATE_FIXED_WINDOW, + RATE_SLIDING_COUNTER, + RATE_TOKEN_BUCKET, + RATE_FIXED_WINDOW_V2, + RATE_SLIDING_COUNTER_V2, + RATE_TOKEN_BUCKET_V2 -> + true; + default -> false; + }; + } + + private static boolean isRateV2(RedisProgramId id) { + return switch (id) { + case RATE_FIXED_WINDOW_V2, RATE_SLIDING_COUNTER_V2, RATE_TOKEN_BUCKET_V2 -> true; + default -> false; + }; + } + + private static boolean isIdempotency(RedisProgramId id) { + return switch (id) { + case IDEMPOTENCY_CLAIM_V1, + IDEMPOTENCY_START_V1, + IDEMPOTENCY_RENEW_V1, + IDEMPOTENCY_COMPLETE_V1, + IDEMPOTENCY_FAIL_V1, + IDEMPOTENCY_RELEASE_V1, + IDEMPOTENCY_INSPECT_V1 -> + true; + default -> false; + }; + } + + private static boolean isLease(RedisProgramId id) { + return switch (id) { + case LEASE_ACQUIRE_V1, LEASE_INSPECT_V1, LEASE_RENEW_V1, LEASE_RELEASE_V1 -> true; + default -> false; + }; + } + + private static boolean isSession(RedisProgramId id) { + return switch (id) { + case SESSION_CREATE_V1, + SESSION_INSPECT_V1, + SESSION_SAVE_IF_LIVE_V1, + SESSION_TOUCH_IF_LIVE_V1, + SESSION_TOMBSTONE_AND_DELETE_V1, + SESSION_ROTATE_V1 -> + true; + default -> false; + }; + } + + private static boolean isPrimitiveAtomic(RedisProgramId id) { + return switch (id) { + case INCREMENT_WITH_INITIAL_TTL_V1, + COMPARE_AND_SET_WITH_TTL_V1, + BOUNDED_SET_ADMISSION_V1, + BOUNDED_LIST_ADMISSION_V1, + HASH_REVISION_CAS_V1, + BOUNDED_HASH_FIELD_ADMISSION_V1, + BOUNDED_ZSET_ADMISSION_V1, + ZSET_BOUNDED_TRIM_V1, + GUARDED_LIST_TRIM_V1, + BOUNDED_GEO_ADMISSION_V1, + BOUNDED_MGET_V1, + BOUNDED_HASH_SCAN_PAGE_V1, + BOUNDED_SET_SCAN_PAGE_V1 -> + true; + default -> false; + }; + } + + private static String slotRule(RedisProgramId id) { + if (id == RedisProgramId.SESSION_ROTATE_V1) { + return "CROSS_SLOT_UNSUPPORTED"; + } + return keyNames(id).size() == 1 ? "SINGLE_KEY" : "SAME_RESOURCE_HASH_TAG"; + } + + private static RedisProgramDescriptor leaseDescriptor( + RedisProgramId id, int argumentCount, Set statuses) { + byte[] script = readResource(id.scriptResource()); + RedisProgramContract contract = contract(id, 512, 128, 6, 128); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + 1, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + 6, + 128, + statuses, + contract); + } + + private static RedisProgramDescriptor sessionDescriptor( + RedisProgramId id, + int keyCount, + int argumentCount, + int replyFieldCount, + Set statuses) { + byte[] script = readResource(id.scriptResource()); + int maximumReplyFieldBytes = replyFieldCount == 1 ? 128 : 1_398_104; + RedisProgramContract contract = + contract(id, 512, 1_398_104, replyFieldCount, maximumReplyFieldBytes); + return new RedisProgramDescriptor( + id, + HEX.formatHex(sha256(script)), + script, + keyCount, + argumentCount, + contract.maximumKeyBytes(), + contract.maximumArgumentBytes(), + replyFieldCount, + contract.resultSchema().maximumFieldBytes(), + statuses, + contract); } private static byte[] readResource(String resource) { diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramContract.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramContract.java new file mode 100644 index 0000000..e0e50c8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramContract.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Machine-readable operational contract paired with one closed-catalog Redis program. */ +record RedisProgramContract( + String semanticVersion, + String libraryName, + String registeredFunctionName, + List keys, + List arguments, + ResultSchema resultSchema, + String slotRule, + StateBound state, + TtlBound ttl, + List validateBeforeFirstWrite, + String complexity, + int maximumIterations, + String stateGrowth, + String clock, + String minimumRedisVersion, + String retrySafety, + String timeoutCertainty, + Set aclCommands) { + + RedisProgramContract { + requirePattern(semanticVersion, "[1-9][0-9]*\\.[0-9]+\\.[0-9]+", "semantic version"); + requirePattern(libraryName, "[a-z][a-z0-9_]{2,63}", "library name"); + requirePattern(registeredFunctionName, "[a-z][a-z0-9_]{2,127}", "registered function name"); + keys = validatedInputs(keys, "KEYS", true); + arguments = validatedInputs(arguments, "ARGV", false); + Objects.requireNonNull(resultSchema, "result schema must be non-null"); + requireText(slotRule, "slot rule"); + Objects.requireNonNull(state, "state bound must be non-null"); + Objects.requireNonNull(ttl, "TTL bound must be non-null"); + validateBeforeFirstWrite = List.copyOf(validateBeforeFirstWrite); + if (validateBeforeFirstWrite.isEmpty() + || validateBeforeFirstWrite.stream().anyMatch(value -> value == null || value.isBlank())) { + throw new IllegalArgumentException("first-write validation contract must be non-empty"); + } + requireText(complexity, "complexity"); + if (maximumIterations < 0 || maximumIterations > 4096) { + throw new IllegalArgumentException("maximum iterations must be bounded"); + } + requireText(stateGrowth, "state growth"); + requireText(clock, "clock"); + requirePattern(minimumRedisVersion, "[1-9][0-9]*\\.[0-9]+", "minimum Redis version"); + requireText(retrySafety, "retry safety"); + requireText(timeoutCertainty, "timeout certainty"); + aclCommands = Set.copyOf(aclCommands); + if (aclCommands.isEmpty() + || aclCommands.stream() + .anyMatch(command -> !command.matches("[A-Z][A-Z0-9]*(\\|[A-Z][A-Z0-9]*)?"))) { + throw new IllegalArgumentException("ACL commands must be a non-empty exact command set"); + } + } + + int maximumKeyBytes() { + return keys.stream().mapToInt(Input::maximumBytes).max().orElseThrow(); + } + + int maximumArgumentBytes() { + return arguments.stream().mapToInt(Input::maximumBytes).max().orElseThrow(); + } + + record Input(int index, String name, String type, int maximumBytes, String sameSlotGroup) { + + Input { + if (index < 1 || index > 64) { + throw new IllegalArgumentException("program input index must be in 1..64"); + } + requirePattern(name, "[a-z][A-Za-z0-9]{1,63}", "program input name"); + requireText(type, "program input type"); + if (maximumBytes < 1 || maximumBytes > 16_778_272) { + throw new IllegalArgumentException("program input byte bound is invalid"); + } + sameSlotGroup = Objects.requireNonNullElse(sameSlotGroup, ""); + } + } + + record ResultSchema( + int version, int fieldCount, int maximumFieldBytes, List orderedFields) { + + ResultSchema { + if (version < 1 || fieldCount < 1 || fieldCount > 16 || maximumFieldBytes < 1) { + throw new IllegalArgumentException("program result schema bounds are invalid"); + } + orderedFields = List.copyOf(orderedFields); + if (orderedFields.size() != fieldCount + || orderedFields.stream() + .anyMatch(field -> field == null || !field.matches("[a-z][A-Za-z0-9]{1,63}"))) { + throw new IllegalArgumentException("program result fields must be exact and ordered"); + } + } + } + + record StateBound(String type, int maximumBytes, int maximumEntries) { + + StateBound { + requireText(type, "state type"); + if (maximumBytes < 0 || maximumBytes > 16_777_216) { + throw new IllegalArgumentException("state byte bound is invalid"); + } + if (maximumEntries < 0 || maximumEntries > 4096) { + throw new IllegalArgumentException("state entry bound is invalid"); + } + } + } + + record TtlBound(String mode, long minimumMillis, long maximumMillis) { + + TtlBound { + requireText(mode, "TTL mode"); + if (minimumMillis < 0 || maximumMillis < minimumMillis || maximumMillis > 2_678_400_000L) { + throw new IllegalArgumentException("TTL bound is invalid"); + } + } + } + + private static List validatedInputs( + List values, String collectionName, boolean requireSlotGroup) { + List inputs = List.copyOf(values); + if (inputs.isEmpty()) { + throw new IllegalArgumentException(collectionName + " must be non-empty"); + } + for (int index = 0; index < inputs.size(); index++) { + Input input = inputs.get(index); + if (input.index() != index + 1) { + throw new IllegalArgumentException(collectionName + " indices must be contiguous"); + } + if (requireSlotGroup && input.sameSlotGroup().isBlank()) { + throw new IllegalArgumentException("every Redis key must declare a same-slot group"); + } + } + return inputs; + } + + private static void requirePattern(String value, String pattern, String field) { + if (value == null || !value.matches(pattern)) { + throw new IllegalArgumentException(field + " is invalid"); + } + } + + private static void requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must be non-blank"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java index d029756..ebf3310 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramDescriptor.java @@ -1,5 +1,6 @@ package dev.caskeleton.adapter.outbound.cache.redis; +import java.util.Locale; import java.util.Objects; import java.util.Set; @@ -13,7 +14,10 @@ final class RedisProgramDescriptor { private final int argumentCount; private final int maximumKeyBytes; private final int maximumArgumentBytes; + private final int replyFieldCount; + private final int maximumReplyFieldBytes; private final Set statuses; + private final RedisProgramContract contract; RedisProgramDescriptor( RedisProgramId id, @@ -24,6 +28,57 @@ final class RedisProgramDescriptor { int maximumKeyBytes, int maximumArgumentBytes, Set statuses) { + this( + id, + sha256, + scriptBytes, + keyCount, + argumentCount, + maximumKeyBytes, + maximumArgumentBytes, + 1, + 128, + statuses, + null); + } + + RedisProgramDescriptor( + RedisProgramId id, + String sha256, + byte[] scriptBytes, + int keyCount, + int argumentCount, + int maximumKeyBytes, + int maximumArgumentBytes, + int replyFieldCount, + int maximumReplyFieldBytes, + Set statuses) { + this( + id, + sha256, + scriptBytes, + keyCount, + argumentCount, + maximumKeyBytes, + maximumArgumentBytes, + replyFieldCount, + maximumReplyFieldBytes, + statuses, + null); + } + + RedisProgramDescriptor( + RedisProgramId id, + String sha256, + byte[] scriptBytes, + int keyCount, + int argumentCount, + int maximumKeyBytes, + int maximumArgumentBytes, + int replyFieldCount, + int maximumReplyFieldBytes, + Set statuses, + RedisProgramContract contract) { this.id = Objects.requireNonNull(id, "id must be non-null"); if (sha256 == null || !sha256.matches("[0-9a-f]{64}")) { throw new IllegalArgumentException("sha256 must be 64 lowercase hexadecimal characters"); @@ -44,10 +99,28 @@ final class RedisProgramDescriptor { } this.maximumKeyBytes = maximumKeyBytes; this.maximumArgumentBytes = maximumArgumentBytes; + if (replyFieldCount < 1 || replyFieldCount > 16 || maximumReplyFieldBytes < 1) { + throw new IllegalArgumentException("program reply bounds are invalid"); + } + this.replyFieldCount = replyFieldCount; + this.maximumReplyFieldBytes = maximumReplyFieldBytes; this.statuses = Set.copyOf(statuses); if (this.statuses.isEmpty()) { throw new IllegalArgumentException("program statuses must be non-empty"); } + this.contract = contract; + if (contract != null + && (contract.keys().size() != keyCount + || contract.arguments().size() != argumentCount + || contract.maximumKeyBytes() != maximumKeyBytes + || contract.maximumArgumentBytes() != maximumArgumentBytes + || contract.resultSchema().fieldCount() != replyFieldCount + || contract.resultSchema().maximumFieldBytes() != maximumReplyFieldBytes + || !contract + .timeoutCertainty() + .equals(contract.timeoutCertainty().toUpperCase(Locale.ROOT)))) { + throw new IllegalArgumentException("program operational contract does not match signature"); + } } RedisProgramId id() { @@ -78,7 +151,22 @@ final class RedisProgramDescriptor { return maximumArgumentBytes; } + int replyFieldCount() { + return replyFieldCount; + } + + int maximumReplyFieldBytes() { + return maximumReplyFieldBytes; + } + Set statuses() { return statuses; } + + RedisProgramContract contract() { + if (contract == null) { + throw new IllegalStateException("program operational contract is missing"); + } + return contract; + } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java index bf1af80..31c4645 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramExecutor.java @@ -1,7 +1,5 @@ package dev.caskeleton.adapter.outbound.cache.redis; -import java.util.List; - /** * Adapter-internal execution seam. Implementations may use Functions or EVALSHA, but application * code must only depend on semantic ports and typed facades. @@ -9,5 +7,5 @@ import java.util.List; @FunctionalInterface interface RedisProgramExecutor { - String execute(RedisProgramDescriptor descriptor, List keys, List arguments); + String execute(RedisCatalogProgramInvocation invocation); } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java index e811fdf..b7af251 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramId.java @@ -2,10 +2,65 @@ package dev.caskeleton.adapter.outbound.cache.redis; /** Versioned Redis atomic programs available in the foundation catalog. */ enum RedisProgramId { + BOUNDED_GET_V1("bounded-get-v1", "redis/scripts/bounded-get-v1.lua"), COMPARE_AND_DELETE("compare-and-delete-v1", "redis/scripts/compare-and-delete-v1.lua"), COMPARE_AND_EXPIRE("compare-and-expire-v1", "redis/scripts/compare-and-expire-v1.lua"), SET_IF_ABSENT_WITH_TTL( - "set-if-absent-with-ttl-v1", "redis/scripts/set-if-absent-with-ttl-v1.lua"); + "set-if-absent-with-ttl-v1", "redis/scripts/set-if-absent-with-ttl-v1.lua"), + REPLACE_IF_OBSERVED_WITH_TTL( + "replace-if-observed-with-ttl-v1", "redis/scripts/replace-if-observed-with-ttl-v1.lua"), + REGION_GENERATION_INIT( + "region-generation-init-v1", "redis/scripts/region-generation-init-v1.lua"), + REGION_GENERATION_BUMP( + "region-generation-bump-v1", "redis/scripts/region-generation-bump-v1.lua"), + CACHE_REFRESH_CLAIM("cache-refresh-claim-v1", "redis/scripts/cache-refresh-claim-v1.lua"), + INCREMENT_WITH_INITIAL_TTL_V1( + "increment-with-initial-ttl-v1", "redis/scripts/increment-with-initial-ttl-v1.lua"), + COMPARE_AND_SET_WITH_TTL_V1( + "compare-and-set-with-ttl-v1", "redis/scripts/compare-and-set-with-ttl-v1.lua"), + BOUNDED_SET_ADMISSION_V1( + "bounded-set-admission-v1", "redis/scripts/bounded-set-admission-v1.lua"), + BOUNDED_LIST_ADMISSION_V1( + "bounded-list-admission-v1", "redis/scripts/bounded-list-admission-v1.lua"), + HASH_REVISION_CAS_V1("hash-revision-cas-v1", "redis/scripts/hash-revision-cas-v1.lua"), + BOUNDED_HASH_FIELD_ADMISSION_V1( + "bounded-hash-field-admission-v1", "redis/scripts/bounded-hash-field-admission-v1.lua"), + BOUNDED_ZSET_ADMISSION_V1( + "bounded-zset-admission-v1", "redis/scripts/bounded-zset-admission-v1.lua"), + ZSET_BOUNDED_TRIM_V1("zset-bounded-trim-v1", "redis/scripts/zset-bounded-trim-v1.lua"), + GUARDED_LIST_TRIM_V1("guarded-list-trim-v1", "redis/scripts/guarded-list-trim-v1.lua"), + BOUNDED_GEO_ADMISSION_V1( + "bounded-geo-admission-v1", "redis/scripts/bounded-geo-admission-v1.lua"), + BOUNDED_MGET_V1("bounded-mget-v1", "redis/scripts/bounded-mget-v1.lua"), + BOUNDED_HASH_SCAN_PAGE_V1( + "bounded-hash-scan-page-v1", "redis/scripts/bounded-hash-scan-page-v1.lua"), + BOUNDED_SET_SCAN_PAGE_V1( + "bounded-set-scan-page-v1", "redis/scripts/bounded-set-scan-page-v1.lua"), + RATE_FIXED_WINDOW("rate-fixed-window-v1", "redis/scripts/rate-fixed-window-v1.lua"), + RATE_SLIDING_COUNTER("rate-sliding-counter-v1", "redis/scripts/rate-sliding-counter-v1.lua"), + RATE_TOKEN_BUCKET("rate-token-bucket-v1", "redis/scripts/rate-token-bucket-v1.lua"), + RATE_FIXED_WINDOW_V2("rate-fixed-window-v2", "redis/scripts/rate-fixed-window-v2.lua"), + RATE_SLIDING_COUNTER_V2("rate-sliding-counter-v2", "redis/scripts/rate-sliding-counter-v2.lua"), + RATE_TOKEN_BUCKET_V2("rate-token-bucket-v2", "redis/scripts/rate-token-bucket-v2.lua"), + IDEMPOTENCY_CLAIM_V1("idempotency-claim-v1", "redis/scripts/idempotency-claim-v1.lua"), + IDEMPOTENCY_START_V1("idempotency-start-v1", "redis/scripts/idempotency-start-v1.lua"), + IDEMPOTENCY_RENEW_V1("idempotency-renew-v1", "redis/scripts/idempotency-renew-v1.lua"), + IDEMPOTENCY_COMPLETE_V1("idempotency-complete-v1", "redis/scripts/idempotency-complete-v1.lua"), + IDEMPOTENCY_FAIL_V1("idempotency-fail-v1", "redis/scripts/idempotency-fail-v1.lua"), + IDEMPOTENCY_RELEASE_V1("idempotency-release-v1", "redis/scripts/idempotency-release-v1.lua"), + IDEMPOTENCY_INSPECT_V1("idempotency-inspect-v1", "redis/scripts/idempotency-inspect-v1.lua"), + LEASE_ACQUIRE_V1("lease-acquire-v1", "redis/scripts/lease-acquire-v1.lua"), + LEASE_INSPECT_V1("lease-inspect-v1", "redis/scripts/lease-inspect-v1.lua"), + LEASE_RENEW_V1("lease-renew-v1", "redis/scripts/lease-renew-v1.lua"), + LEASE_RELEASE_V1("lease-release-v1", "redis/scripts/lease-release-v1.lua"), + SESSION_CREATE_V1("session-create-v1", "redis/scripts/session-create-v1.lua"), + SESSION_INSPECT_V1("session-inspect-v1", "redis/scripts/session-inspect-v1.lua"), + SESSION_SAVE_IF_LIVE_V1("session-save-if-live-v1", "redis/scripts/session-save-if-live-v1.lua"), + SESSION_TOUCH_IF_LIVE_V1( + "session-touch-if-live-v1", "redis/scripts/session-touch-if-live-v1.lua"), + SESSION_TOMBSTONE_AND_DELETE_V1( + "session-tombstone-and-delete-v1", "redis/scripts/session-tombstone-and-delete-v1.lua"), + SESSION_ROTATE_V1("session-rotate-v1", "redis/scripts/session-rotate-v1.lua"); private final String externalId; private final String scriptResource; diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfig.java new file mode 100644 index 0000000..027d3f6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfig.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import java.time.Clock; +import java.util.Arrays; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Canonical coordination-role Redis composition for edge rate limiting. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(RedisRateLimitSettings.class) +@ConditionalOnProperty( + name = "ca-skeleton.capabilities.rate-limit.provider", + havingValue = "redis", + matchIfMissing = false) +public class RedisRateLimitConfig { + + @Bean(name = "distributedRateLimiter", destroyMethod = "close") + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.rate-limit.provider", + havingValue = "redis", + matchIfMissing = false) + EdgeRateLimitPort distributedRateLimiter( + RedisRateLimitSettings settings, + RedisProviderSettings providerProperties, + RedisCanonicalRoleRegistry roleRegistry, + RedisCredentialMaterialProvider credentialProvider, + ObjectProvider clockProvider, + ObjectProvider observationsProvider) { + Clock clock = clockProvider.getIfAvailable(Clock::systemUTC); + RedisCapabilityObservationPort observations = + observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance); + byte[] hmacSecret = + RedisHmacMaterialResolver.resolve( + settings.keyHmacSecretReference(), credentialProvider, clock, "rate-limit"); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + try { + return new RedisEdgeRateLimitProvider( + settings.compiledPolicies(), + catalog, + new RedisStructuredProgramExecutor(catalog, roleRegistry.router(RedisRole.COORDINATION)), + settings.namespaceApplication(), + settings.namespaceEnvironment(), + settings.hashKeyVersion(), + settings.keyVersion(), + hmacSecret, + clock, + settings.failureRetryAfter(), + providerProperties.runtime().commandTimeout(), + observations, + System::nanoTime); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitRuntime.java new file mode 100644 index 0000000..df79530 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitRuntime.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; + +/** + * Dedicated coordination-role runtime wrapper. It deliberately does not implement the legacy cache + * {@link RedisClient} or expose the cache runtime type as a Spring bean. + */ +final class RedisRateLimitRuntime implements RedisStructuredCommands, AutoCloseable { + + private final LettuceRedisRuntime delegate; + + private RedisRateLimitRuntime(LettuceRedisRuntime delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null"); + } + + static RedisRateLimitRuntime connect(RedisLegacyStandaloneSettings settings) { + return new RedisRateLimitRuntime(LettuceRedisRuntime.connect(settings)); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) { + return delegate.executeCatalogProgram(invocation); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return delegate.loadCatalogProgram(invocation); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettings.java new file mode 100644 index 0000000..e9537ac --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettings.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Strict canonical settings for the Redis edge-rate-limit provider. */ +@ConfigurationProperties(prefix = "ca-skeleton.capabilities.rate-limit") +public record RedisRateLimitSettings( + Provider provider, + RateLimitFailurePolicy failurePolicy, + String defaultPolicyId, + Duration failureRetryAfter, + int hashKeyVersion, + int keyVersion, + String keyHmacSecretReference, + String namespaceApplication, + String namespaceEnvironment, + Map policies) { + + private static final Duration MAXIMUM_RETRY_AFTER = Duration.ofDays(30); + + @ConstructorBinding + public RedisRateLimitSettings { + provider = provider == null ? Provider.DISABLED : provider; + failurePolicy = failurePolicy == null ? RateLimitFailurePolicy.FAIL_CLOSED : failurePolicy; + defaultPolicyId = defaultText(defaultPolicyId, "api-default"); + failureRetryAfter = failureRetryAfter == null ? Duration.ofMillis(100) : failureRetryAfter; + hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion; + keyVersion = keyVersion == 0 ? 1 : keyVersion; + keyHmacSecretReference = keyHmacSecretReference == null ? "" : keyHmacSecretReference.trim(); + namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); + namespaceEnvironment = defaultText(namespaceEnvironment, "local"); + policies = policies == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(policies)); + + positive(failureRetryAfter, MAXIMUM_RETRY_AFTER, "rate-limit failure retry-after"); + if (hashKeyVersion < 1 || hashKeyVersion > 9999 || keyVersion < 1 || keyVersion > 9999) { + throw new IllegalArgumentException("rate-limit key versions must be in 1..9999"); + } + if (provider == Provider.REDIS) { + if (failurePolicy != RateLimitFailurePolicy.FAIL_CLOSED) { + throw new IllegalArgumentException("only failure-policy=fail-closed is supported in v1"); + } + if (!keyHmacSecretReference.startsWith("secret://") + || keyHmacSecretReference.length() > 512 + || keyHmacSecretReference.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException( + "rate-limit HMAC material must use a bounded secret:// reference"); + } + slug(namespaceApplication, "rate-limit namespace application"); + slug(namespaceEnvironment, "rate-limit namespace environment"); + Map compiled = compilePolicies(policies, failurePolicy); + if (!compiled.containsKey(defaultPolicyId)) { + throw new IllegalArgumentException( + "default-policy-id must reference an exact configured policy"); + } + } + } + + Map compiledPolicies() { + return compilePolicies(policies, failurePolicy); + } + + public enum Provider { + DISABLED, + REDIS + } + + public record PolicyDefinition( + String revision, + RateLimitAlgorithm algorithm, + Long limit, + Duration window, + Long capacity, + Long refillTokens, + Duration refillPeriod, + Long maximumCost, + Duration cleanupGrace, + Duration maximumClockRegression, + Boolean evaluationDedupEnabled, + Duration evaluationDedupTtl, + Integer evaluationDedupMaximumEntries, + Integer evaluationDedupMaximumStoredBytes) { + + RateLimitPolicy compile(String policyId, RateLimitFailurePolicy failurePolicy) { + Objects.requireNonNull(algorithm, "rate-limit policy algorithm must be configured"); + long cost = + Objects.requireNonNull(maximumCost, "rate-limit policy maximum-cost must be configured"); + Duration grace = + Objects.requireNonNull( + cleanupGrace, "rate-limit policy cleanup-grace must be configured"); + Duration clockRegression = + Objects.requireNonNull( + maximumClockRegression, + "rate-limit policy maximum-clock-regression must be configured"); + RateLimitEvaluationDedupPolicy evaluationDedup = + evaluationDedupEnabled == null || evaluationDedupEnabled + ? new RateLimitEvaluationDedupPolicy( + true, + evaluationDedupTtl == null ? Duration.ofSeconds(5) : evaluationDedupTtl, + evaluationDedupMaximumEntries == null ? 256 : evaluationDedupMaximumEntries, + evaluationDedupMaximumStoredBytes == null + ? 65_536 + : evaluationDedupMaximumStoredBytes) + : RateLimitEvaluationDedupPolicy.disabled(); + RateParameters parameters = + switch (algorithm) { + case FIXED_WINDOW -> + new RateParameters.FixedWindow( + required(limit, "limit"), required(window, "window")); + case SLIDING_COUNTER -> + new RateParameters.SlidingCounter( + required(limit, "limit"), required(window, "window")); + case TOKEN_BUCKET -> + new RateParameters.TokenBucket( + required(capacity, "capacity"), + required(refillTokens, "refill-tokens"), + required(refillPeriod, "refill-period")); + }; + return new RateLimitPolicy( + policyId, + required(revision, "revision"), + algorithm, + parameters, + cost, + grace, + clockRegression, + failurePolicy, + evaluationDedup); + } + } + + private static Map compilePolicies( + Map definitions, RateLimitFailurePolicy failurePolicy) { + if (definitions.isEmpty()) { + throw new IllegalArgumentException("at least one exact rate-limit policy must be configured"); + } + Map compiled = new LinkedHashMap<>(); + definitions.forEach( + (policyId, definition) -> { + if (definition == null) { + throw new IllegalArgumentException("rate-limit policy must be non-null"); + } + compiled.put(policyId, definition.compile(policyId, failurePolicy)); + }); + return Map.copyOf(compiled); + } + + private static T required(T value, String field) { + return Objects.requireNonNull(value, "rate-limit policy " + field + " must be configured"); + } + + private static void positive(Duration value, Duration maximum, String field) { + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + } + + private static void slug(String value, String field) { + if (!value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException(field + " has invalid format"); + } + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramDecision.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramDecision.java new file mode 100644 index 0000000..5308499 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramDecision.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Allow/deny meaning carried separately from the v2 replay status. */ +enum RedisRateProgramDecision { + ALLOWED, + DENIED, + NONE +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramExecutor.java new file mode 100644 index 0000000..6c6817b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramExecutor.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Adapter-internal seam for one exact structured rate-limit program invocation. */ +@FunctionalInterface +interface RedisRateProgramExecutor { + + RedisRateProgramReply execute(RedisCatalogProgramInvocation invocation); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramReply.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramReply.java new file mode 100644 index 0000000..8e5f935 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramReply.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Parsed decision shared by compatible v1 and replay-aware v2 rate-limit programs. */ +record RedisRateProgramReply( + RedisRateProgramStatus status, + RedisRateProgramDecision decision, + long serverNowMillis, + long effectiveNowMillis, + long limit, + long remaining, + long retryAfterMillis, + long resetAtMillis) { + + RedisRateProgramReply( + RedisRateProgramStatus status, + long serverNowMillis, + long effectiveNowMillis, + long limit, + long remaining, + long retryAfterMillis, + long resetAtMillis) { + this( + status, + switch (status) { + case ALLOWED -> RedisRateProgramDecision.ALLOWED; + case DENIED -> RedisRateProgramDecision.DENIED; + default -> RedisRateProgramDecision.NONE; + }, + serverNowMillis, + effectiveNowMillis, + limit, + remaining, + retryAfterMillis, + resetAtMillis); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramStatus.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramStatus.java new file mode 100644 index 0000000..d7c7dbe --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramStatus.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Exact statuses returned by the v1 distributed rate-limit programs. */ +enum RedisRateProgramStatus { + ALLOWED, + DENIED, + DEDUP_REPLAY, + CLOCK_UNSAFE, + STATE_INCOMPATIBLE, + INVALID +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouter.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouter.java new file mode 100644 index 0000000..f6c646b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouter.java @@ -0,0 +1,1117 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.LongSupplier; + +/** + * Role-local atomic command router with bounded admission and old-runtime drain. + * + *

The type is package-private so application/domain consumers cannot obtain Redis commands. + */ +final class RedisRoleCommandRouter + implements RedisBinaryCommands, + RedisPrimitiveCommands, + RedisStructuredCommands, + RedisInvalidationTransport, + RedisClient, + AutoCloseable { + + private static final int MAXIMUM_COLLECTION_ELEMENTS = 256; + private static final long RECENT_COMMAND_FAILURE_NANOS = Duration.ofSeconds(30).toNanos(); + + enum SwapResult { + DRAINED, + FORCED_AFTER_TIMEOUT, + PROBE_FAILED, + STALE_GENERATION, + SAME_ROUTE + } + + record RouteToken(long generation, RedisRouteIdentity identity) { + + RouteToken { + if (generation < 0) { + throw new IllegalArgumentException("Redis route generation must be non-negative"); + } + Objects.requireNonNull(identity, "Redis route identity must be non-null"); + } + } + + @FunctionalInterface + interface TopologyFailureListener { + + void requestRecovery(RouteToken failedRoute, RedisCommandFailureException failure); + + static TopologyFailureListener ignore() { + return (failedRoute, failure) -> {}; + } + } + + private final AtomicReference active; + private final int maximumInFlight; + private final int maximumCommandBytes; + private final long maximumInFlightBytes; + private final AtomicLong inFlightBytes = new AtomicLong(); + private final AtomicLong lastCommandFailureNanos = new AtomicLong(); + private final AtomicBoolean commandFailureObserved = new AtomicBoolean(); + private final LongSupplier ticker; + private final Duration defaultWriteTtl; + private final Duration closeDrainTimeout; + private final RedisRole boundRole; + private final RedisCapabilityObservationPort observations; + private final RedisCapabilityObserver commandObserver; + private final RedisDrainWaiter drainWaiter; + private final TopologyFailureListener topologyFailureListener; + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisRoleCommandRouter( + RedisRoutableCommandRuntime initial, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration closeDrainTimeout, + Duration defaultWriteTtl) { + this( + null, + initial, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + closeDrainTimeout, + defaultWriteTtl, + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + RedisDrainWaiter.system()); + } + + RedisRoleCommandRouter( + RedisRole boundRole, + RedisRoutableCommandRuntime initial, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration closeDrainTimeout, + Duration defaultWriteTtl) { + this( + boundRole, + initial, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + closeDrainTimeout, + defaultWriteTtl, + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + RedisDrainWaiter.system()); + } + + RedisRoleCommandRouter( + RedisRoutableCommandRuntime initial, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration closeDrainTimeout, + Duration defaultWriteTtl, + LongSupplier ticker) { + this( + null, + initial, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + closeDrainTimeout, + defaultWriteTtl, + ticker, + NoOpRedisCapabilityObservationPort.instance(), + RedisDrainWaiter.system()); + } + + RedisRoleCommandRouter( + RedisRole boundRole, + RedisRoutableCommandRuntime initial, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration closeDrainTimeout, + Duration defaultWriteTtl, + LongSupplier ticker) { + this( + boundRole, + initial, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + closeDrainTimeout, + defaultWriteTtl, + ticker, + NoOpRedisCapabilityObservationPort.instance(), + RedisDrainWaiter.system()); + } + + RedisRoleCommandRouter( + RedisRole boundRole, + RedisRoutableCommandRuntime initial, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration closeDrainTimeout, + Duration defaultWriteTtl, + LongSupplier ticker, + RedisCapabilityObservationPort observations, + RedisDrainWaiter drainWaiter) { + this( + boundRole, + initial, + maximumInFlight, + maximumCommandBytes, + maximumInFlightBytes, + closeDrainTimeout, + defaultWriteTtl, + ticker, + observations, + drainWaiter, + TopologyFailureListener.ignore()); + } + + RedisRoleCommandRouter( + RedisRole boundRole, + RedisRoutableCommandRuntime initial, + int maximumInFlight, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration closeDrainTimeout, + Duration defaultWriteTtl, + LongSupplier ticker, + RedisCapabilityObservationPort observations, + RedisDrainWaiter drainWaiter, + TopologyFailureListener topologyFailureListener) { + if (maximumInFlight < 1 || maximumInFlight > 4096) { + throw new IllegalArgumentException("Redis router in-flight bound must be in 1..4096"); + } + if (defaultWriteTtl == null + || defaultWriteTtl.isZero() + || defaultWriteTtl.isNegative() + || defaultWriteTtl.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException("Redis router default TTL must be positive and bounded"); + } + if (maximumCommandBytes < 1024 || maximumCommandBytes > 16_777_216) { + throw new IllegalArgumentException( + "Redis router command byte bound must be in 1024..16777216"); + } + if (maximumInFlightBytes < maximumCommandBytes || maximumInFlightBytes > 268_435_456L) { + throw new IllegalArgumentException( + "Redis router in-flight byte budget must cover one command and be bounded"); + } + positiveBounded(closeDrainTimeout, "Redis route close drain timeout"); + this.maximumInFlight = maximumInFlight; + this.maximumCommandBytes = maximumCommandBytes; + this.maximumInFlightBytes = maximumInFlightBytes; + this.closeDrainTimeout = closeDrainTimeout; + this.boundRole = boundRole; + this.defaultWriteTtl = defaultWriteTtl; + this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null"); + this.observations = + new SafeRedisCapabilityObservationPort( + Objects.requireNonNull(observations, "observations must be non-null")); + this.commandObserver = new RedisCapabilityObserver(this.observations, this.ticker); + this.drainWaiter = Objects.requireNonNull(drainWaiter, "drainWaiter must be non-null"); + this.topologyFailureListener = + Objects.requireNonNull(topologyFailureListener, "topologyFailureListener must be non-null"); + this.active = + new AtomicReference<>( + new RouteState( + Objects.requireNonNull(initial, "initial must be non-null"), + 0, + maximumInFlight, + this.drainWaiter)); + } + + synchronized SwapResult swap( + RedisRoutableCommandRuntime candidate, Duration probeTimeout, Duration drainTimeout) { + return swapCandidate(null, candidate, probeTimeout, drainTimeout); + } + + synchronized SwapResult swapIfGeneration( + RouteToken expected, + RedisRoutableCommandRuntime candidate, + Duration probeTimeout, + Duration drainTimeout) { + Objects.requireNonNull(expected, "expected route token must be non-null"); + return swapCandidate(expected, candidate, probeTimeout, drainTimeout); + } + + private SwapResult swapCandidate( + RouteToken expected, + RedisRoutableCommandRuntime candidate, + Duration probeTimeout, + Duration drainTimeout) { + Objects.requireNonNull(candidate, "candidate must be non-null"); + positiveBounded(probeTimeout, "Redis route probe timeout"); + positiveBounded(drainTimeout, "Redis route drain timeout"); + ensureOpen(); + RouteState current = active.get(); + if (expected != null && !expected.equals(current.token())) { + closeUnlessActive(candidate, current); + return SwapResult.STALE_GENERATION; + } + if (candidate == current.runtime) { + if (expected != null) { + return SwapResult.SAME_ROUTE; + } + throw new IllegalArgumentException("Redis route candidate must be a new runtime"); + } + RedisRouteIdentity candidateIdentity; + try { + candidateIdentity = + Objects.requireNonNull( + candidate.routeIdentity(), "candidate route identity must be non-null"); + } catch (RuntimeException ignored) { + closeQuietly(candidate); + return SwapResult.PROBE_FAILED; + } + if (expected != null && candidateIdentity.equals(current.identity)) { + closeQuietly(candidate); + return SwapResult.SAME_ROUTE; + } + try { + candidate.probe(probeTimeout); + } catch (RuntimeException ignored) { + closeQuietly(candidate); + return SwapResult.PROBE_FAILED; + } + + List prepared = new ArrayList<>(); + try { + for (ManagedSubscription subscription : subscriptions) { + PreparedSubscription candidateSubscription = subscription.prepare(candidate); + if (candidateSubscription != null) { + prepared.add(candidateSubscription); + } + } + } catch (RuntimeException ignored) { + prepared.forEach(PreparedSubscription::close); + closeQuietly(candidate); + return SwapResult.PROBE_FAILED; + } + + RouteState replacement = + new RouteState( + candidate, + Math.incrementExact(current.generation), + candidateIdentity, + maximumInFlight, + drainWaiter); + active.set(replacement); + commandFailureObserved.set(false); + prepared.forEach(PreparedSubscription::commit); + current.stopAccepting(); + RedisDrainWaiter.Result drainResult = current.awaitDrain(drainTimeout); + closeQuietly(current.runtime); + return drainResult == RedisDrainWaiter.Result.DRAINED + ? SwapResult.DRAINED + : SwapResult.FORCED_AFTER_TIMEOUT; + } + + RouteToken routeToken() { + ensureOpen(); + RouteState current = active.get(); + if (current == null) { + throw new IllegalStateException("Redis role command route is unavailable"); + } + return current.token(); + } + + boolean ownsRuntime(RedisRoutableCommandRuntime runtime) { + Objects.requireNonNull(runtime, "runtime must be non-null"); + RouteState current = active.get(); + return current != null && current.runtime == runtime; + } + + synchronized RedisRoutableCommandRuntime releaseQualifiedRuntimeForTransfer() { + ensureOpen(); + if (!subscriptions.isEmpty() || inFlightBytes.get() != 0) { + throw new IllegalStateException("Redis qualified route cannot transfer while in use"); + } + RouteState state = active.getAndSet(null); + if (state == null || !closed.compareAndSet(false, true)) { + throw new IllegalStateException("Redis qualified route is unavailable for transfer"); + } + state.stopAccepting(); + if (state.awaitDrain(closeDrainTimeout) != RedisDrainWaiter.Result.DRAINED) { + active.compareAndSet(null, state); + closed.set(false); + throw new IllegalStateException("Redis qualified route did not drain before transfer"); + } + return state.runtime; + } + + void probe(Duration timeout) { + positiveBounded(timeout, "Redis route probe timeout"); + execute( + 1, + runtime -> { + runtime.probe(timeout); + return null; + }); + } + + boolean isClosed() { + return closed.get(); + } + + boolean hadRecentCommandFailure() { + if (!commandFailureObserved.get()) { + return false; + } + long failedAt = lastCommandFailureNanos.get(); + try { + long elapsed = ticker.getAsLong() - failedAt; + return elapsed >= 0 && elapsed <= RECENT_COMMAND_FAILURE_NANOS; + } catch (RuntimeException ignored) { + return false; + } + } + + @Override + public Optional read(String key) { + byte[] result = get(RedisPhysicalKey.owned(new LegacyKeyMaterial(key))); + return result == null + ? Optional.empty() + : Optional.of(new String(result, StandardCharsets.UTF_8)); + } + + @Override + public void write(String key, String value) { + set( + RedisPhysicalKey.owned(new LegacyKeyMaterial(key)), + RedisBinaryValue.utf8(value), + defaultWriteTtl); + } + + @Override + public byte[] get(RedisPhysicalKey key) { + Objects.requireNonNull(key, "key must be non-null"); + validateInputBytes(key.encodedLength()); + return execute( + maximumCommandBytes, + runtime -> + boundedReply(runtime.get(key), RedisCommandFailureException.Certainty.NOT_APPLIED)); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + Objects.requireNonNull(key, "key must be non-null"); + Objects.requireNonNull(value, "value must be non-null"); + int bytes = checkedInputBytes(key.encodedLength(), value.encodedLength()); + execute( + bytes, + runtime -> { + runtime.set(key, value, timeToLive); + return null; + }); + } + + @Override + public long delete(RedisPhysicalKey key) { + Objects.requireNonNull(key, "key must be non-null"); + validateInputBytes(key.encodedLength()); + return execute(key.encodedLength(), runtime -> runtime.delete(key)); + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + if (boundRole != null && invocation.descriptor().boundRole() != boundRole) { + throw new IllegalArgumentException("Redis primitive is not bound to this canonical role"); + } + try { + invocation.remainingDeadline(); + } catch (RedisCommandFailureException expired) { + observePreDispatchFailure(expired); + throw expired; + } + validateInputBytes(invocation.encodedRequestBytes()); + if (invocation.descriptor().maximumResultBytes() > maximumCommandBytes) { + throw observedOverloaded(); + } + long reserved = + Math.addExact( + invocation.encodedRequestBytes(), invocation.descriptor().maximumResultBytes()); + return execute(reserved, runtime -> runtime.execute(invocation)); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + validateInputBytes(invocation.encodedBytes()); + return execute( + maximumCommandBytes, + runtime -> + boundedCatalogReply( + runtime.executeCatalogProgram(invocation), invocation.replyShape())); + } + + RedisCatalogProgramReply executeCatalogProgramWithRecovery( + RedisCatalogProgramInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + validateInputBytes(invocation.encodedBytes()); + return execute( + maximumCommandBytes, + runtime -> + boundedCatalogReply( + RedisScriptRecovery.executeOnSelectedRuntime(runtime, invocation), + invocation.replyShape())); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + long scriptBytes = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation).length; + validateInputBytes(scriptBytes); + return execute(scriptBytes, runtime -> runtime.loadCatalogProgram(invocation)); + } + + @Override + public long publish(byte[] channel, byte[] message) { + validateAscii(channel, 512, "Redis invalidation channel"); + validateAscii(message, maximumCommandBytes, "Redis invalidation message"); + int bytes = checkedInputBytes(channel.length, message.length); + byte[] safeChannel = channel.clone(); + byte[] safeMessage = message.clone(); + return execute(bytes, runtime -> runtime.publish(safeChannel, safeMessage)); + } + + @Override + public synchronized Subscription subscribe(byte[] channel, Listener listener) { + ensureOpen(); + validateAscii(channel, 512, "Redis invalidation channel"); + Objects.requireNonNull(listener, "Redis invalidation listener must be non-null"); + if (subscriptions.size() >= MAXIMUM_COLLECTION_ELEMENTS) { + throw observedOverloaded(); + } + RouteState state = active.get(); + if (state == null) { + throw unavailable(); + } + ManagedSubscription subscription = new ManagedSubscription(channel.clone(), listener); + subscription.open(state.runtime); + subscriptions.add(subscription); + return subscription; + } + + private T execute(long reservedBytes, Function operation) { + return commandObserver.observe( + RedisCapabilityObservationEvent.Capability.RUNTIME, + observationRole(), + RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND, + () -> executeAuthoritative(reservedBytes, operation), + ignored -> + new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + this::classifyRouteFailure); + } + + private T executeAuthoritative( + long reservedBytes, Function operation) { + RouteToken selectedRoute = null; + try { + try { + ensureOpen(); + } catch (IllegalStateException closedFailure) { + observeAdmission(RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED, null); + throw closedFailure; + } + RouteLease lease = acquire(reservedBytes); + selectedRoute = lease.state.token(); + try (lease) { + return operation.apply(lease.state.runtime); + } + } catch (RedisCommandFailureException failure) { + recordRecentCommandFailure(); + signalTopologyFailure(selectedRoute, failure); + throw failure; + } + } + + private void signalTopologyFailure( + RouteToken selectedRoute, RedisCommandFailureException failure) { + if (selectedRoute == null + || failure.recoveryHint() + != RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL) { + return; + } + try { + topologyFailureListener.requestRecovery(selectedRoute, failure); + } catch (RuntimeException ignored) { + // A recovery request cannot replace the original command failure or its certainty. + } + } + + private void recordRecentCommandFailure() { + try { + lastCommandFailureNanos.set(ticker.getAsLong()); + commandFailureObserved.set(true); + } catch (RuntimeException ignored) { + // A diagnostic clock failure cannot erase an earlier valid recent-failure signal. + } + } + + private RedisCapabilityObserver.Classification classifyRouteFailure(RuntimeException failure) { + if (failure instanceof RedisCommandFailureException commandFailure) { + RedisCapabilityObservationEvent.Outcome outcome = + commandFailure.kind() == RedisCommandFailureException.Kind.OVERLOADED + ? RedisCapabilityObservationEvent.Outcome.OVERLOADED + : RedisCapabilityObservationEvent.Outcome.UNAVAILABLE; + RedisCapabilityObservationEvent.Certainty certainty = + commandFailure.certainty() == RedisCommandFailureException.Certainty.INDETERMINATE + ? RedisCapabilityObservationEvent.Certainty.INDETERMINATE + : RedisCapabilityObservationEvent.Certainty.NOT_APPLIED; + return new RedisCapabilityObserver.Classification(outcome, certainty); + } + if (closed.get() && failure instanceof IllegalStateException) { + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.CLOSED, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + return new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + + private RouteLease acquire(long reservedBytes) { + try { + reserveBytes(reservedBytes); + } catch (RedisCommandFailureException saturated) { + observeAdmission( + RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED, active.get()); + throw saturated; + } + boolean acquired = false; + try { + for (int attempts = 0; attempts < 32; attempts++) { + RouteState state = active.get(); + if (state == null) { + break; + } + RouteState.AcquireResult result = state.tryAcquire(); + if (result == RouteState.AcquireResult.ACQUIRED) { + acquired = true; + observeAdmission(RedisCapabilityObservationEvent.AdmissionState.ADMITTED, state); + return new RouteLease(state, reservedBytes); + } + if (result == RouteState.AcquireResult.SATURATED && state == active.get()) { + observeAdmission( + RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED, state); + throw overloaded(); + } + Thread.onSpinWait(); + } + throw unavailable(); + } finally { + if (!acquired) { + releaseBytes(reservedBytes); + } + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Redis role command router is closed"); + } + } + + @Override + public synchronized void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + RouteState state = active.getAndSet(null); + subscriptions.forEach(ManagedSubscription::close); + subscriptions.clear(); + if (state != null) { + state.stopAccepting(); + RedisDrainWaiter.Result drainResult = state.awaitDrain(closeDrainTimeout); + closeQuietly(state.runtime); + observations.observe( + new RedisCapabilityObservationEvent.LifecycleDrainCompleted( + observationRole(), + switch (drainResult) { + case DRAINED -> RedisCapabilityObservationEvent.DrainOutcome.DRAINED; + case TIMED_OUT -> RedisCapabilityObservationEvent.DrainOutcome.FORCED_AFTER_TIMEOUT; + case INTERRUPTED -> RedisCapabilityObservationEvent.DrainOutcome.INTERRUPTED; + })); + } + } + + private RedisCapabilityObservationEvent.Role observationRole() { + return boundRole == null + ? RedisCapabilityObservationEvent.Role.CACHE + : RedisCapabilityObservationEvent.Role.valueOf(boundRole.name()); + } + + private void observeAdmission( + RedisCapabilityObservationEvent.AdmissionState admission, RouteState state) { + int commandCount = state == null ? 0 : state.inFlight(); + long byteCount = Math.max(0L, Math.min(maximumInFlightBytes, inFlightBytes.get())); + RedisCapabilityObservationEvent.InFlightState inFlightState; + if (commandCount == 0 && byteCount == 0) { + inFlightState = RedisCapabilityObservationEvent.InFlightState.IDLE; + } else if (commandCount >= maximumInFlight || byteCount >= maximumInFlightBytes) { + inFlightState = RedisCapabilityObservationEvent.InFlightState.SATURATED; + } else { + inFlightState = RedisCapabilityObservationEvent.InFlightState.ACTIVE; + } + observations.observe( + new RedisCapabilityObservationEvent.AdmissionChanged( + observationRole(), admission, inFlightState, commandCount, byteCount)); + } + + private static RedisCommandFailureException overloaded() { + return new RedisCommandFailureException( + RedisCommandFailureException.Kind.OVERLOADED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis role command admission is saturated", + null); + } + + private static RedisCommandFailureException unavailable() { + return unavailable(RedisCommandFailureException.Certainty.NOT_APPLIED); + } + + private static RedisCommandFailureException unavailable( + RedisCommandFailureException.Certainty certainty) { + return new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + certainty, + "Redis role command route is unavailable", + null); + } + + private static void positiveBounded(Duration value, String field) { + if (value == null + || value.isZero() + || value.isNegative() + || value.compareTo(Duration.ofSeconds(30)) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + } + + private static void closeQuietly(RedisRoutableCommandRuntime runtime) { + try { + runtime.close(); + } catch (RuntimeException ignored) { + // A failed old-runtime close must not undo an already-installed route. + } + } + + private static void closeUnlessActive( + RedisRoutableCommandRuntime candidate, RouteState activeState) { + if (candidate != activeState.runtime) { + closeQuietly(candidate); + } + } + + private static byte[] copy(byte[] value) { + return Objects.requireNonNull(value, "Redis binary value must be non-null").clone(); + } + + private void validateAscii(byte[] value, int maximumBytes, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.length == 0 || value.length > maximumBytes) { + throw observedOverloaded(); + } + for (byte item : value) { + if ((item & 0x80) != 0) { + throw new IllegalArgumentException(field + " must contain ASCII bytes only"); + } + } + } + + private void validateInputBytes(long bytes) { + if (bytes < 0 || bytes > maximumCommandBytes) { + throw observedOverloaded(); + } + } + + private int checkedInputBytes(long... values) { + long total = 0; + for (long value : values) { + if (value < 0 || total > maximumCommandBytes - value) { + throw observedOverloaded(); + } + total += value; + } + return Math.toIntExact(total); + } + + private RedisCommandFailureException observedOverloaded() { + observeAdmission( + RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED, active.get()); + observations.observe( + new RedisCapabilityObservationEvent.OperationCompleted( + RedisCapabilityObservationEvent.Capability.RUNTIME, + observationRole(), + RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND, + RedisCapabilityObservationEvent.Outcome.OVERLOADED, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED, + 0L)); + return overloaded(); + } + + private void observePreDispatchFailure(RedisCommandFailureException failure) { + RedisCapabilityObserver.Classification classification = classifyRouteFailure(failure); + observations.observe( + new RedisCapabilityObservationEvent.OperationCompleted( + RedisCapabilityObservationEvent.Capability.RUNTIME, + observationRole(), + RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND, + classification.outcome(), + classification.certainty(), + 0L)); + } + + private static long totalBytes(List values) { + long total = 0; + for (byte[] value : values) { + total = Math.addExact(total, value.length); + } + return total; + } + + private RedisCatalogProgramReply boundedCatalogReply( + RedisCatalogProgramReply reply, RedisCatalogProgramInvocation.ReplyShape shape) { + RedisCommandFailureException.Certainty certainty = + switch (shape) { + case READ_ONLY_VALUE, READ_ONLY_MULTI -> + RedisCommandFailureException.Certainty.NOT_APPLIED; + case VALUE, MULTI -> RedisCommandFailureException.Certainty.INDETERMINATE; + }; + return switch (shape) { + case VALUE, READ_ONLY_VALUE -> + RedisCatalogProgramReply.value(boundedReply(reply.copyValue(), certainty)); + case MULTI, READ_ONLY_MULTI -> + RedisCatalogProgramReply.multi(boundedReplies(reply.copyFields(), certainty)); + }; + } + + private byte[] boundedReply(byte[] value, RedisCommandFailureException.Certainty certainty) { + if (value != null && value.length > maximumCommandBytes) { + throw unavailable(certainty); + } + return value; + } + + private List boundedReplies( + List values, RedisCommandFailureException.Certainty certainty) { + if (values == null) { + return null; + } + if (values.size() > MAXIMUM_COLLECTION_ELEMENTS) { + throw unavailable(certainty); + } + long total = totalBytes(values); + if (total > maximumCommandBytes) { + throw unavailable(certainty); + } + return values; + } + + private void reserveBytes(long bytes) { + while (true) { + long current = inFlightBytes.get(); + if (bytes < 0 || current > maximumInFlightBytes - bytes) { + throw overloaded(); + } + if (inFlightBytes.compareAndSet(current, current + bytes)) { + return; + } + } + } + + private void releaseBytes(long bytes) { + long remaining = inFlightBytes.addAndGet(-bytes); + if (remaining < 0) { + throw new IllegalStateException("Redis route byte lease released more than once"); + } + } + + private final class RouteLease implements AutoCloseable { + + private final RouteState state; + private final long reservedBytes; + private final AtomicBoolean released = new AtomicBoolean(); + + private RouteLease(RouteState state, long reservedBytes) { + this.state = state; + this.reservedBytes = reservedBytes; + } + + @Override + public void close() { + if (released.compareAndSet(false, true)) { + try { + state.release(); + } finally { + releaseBytes(reservedBytes); + observeAdmission(RedisCapabilityObservationEvent.AdmissionState.NOT_APPLICABLE, state); + } + } + } + } + + private final class ManagedSubscription implements Subscription { + + private final byte[] channel; + private final Listener listener; + private final AtomicReference binding = new AtomicReference<>(); + private final AtomicBoolean subscriptionClosed = new AtomicBoolean(); + + private ManagedSubscription(byte[] channel, Listener listener) { + this.channel = channel; + this.listener = listener; + } + + private void open(RedisRoutableCommandRuntime runtime) { + PreparedSubscription prepared = prepare(runtime); + if (prepared == null) { + throw new IllegalStateException("Redis invalidation subscription is closed"); + } + prepared.commit(); + } + + private PreparedSubscription prepare(RedisRoutableCommandRuntime runtime) { + if (subscriptionClosed.get()) { + return null; + } + GatedListener gated = new GatedListener(listener); + Subscription delegate = runtime.subscribe(channel.clone(), gated); + return new PreparedSubscription(this, new SubscriptionBinding(delegate, gated)); + } + + private void install(SubscriptionBinding replacement) { + if (subscriptionClosed.get()) { + replacement.close(); + return; + } + SubscriptionBinding previous = binding.getAndSet(replacement); + replacement.activate(); + if (previous != null) { + previous.close(); + } + } + + @Override + public void close() { + if (subscriptionClosed.compareAndSet(false, true)) { + subscriptions.remove(this); + SubscriptionBinding existing = binding.getAndSet(null); + if (existing != null) { + existing.close(); + } + } + } + } + + private static final class PreparedSubscription implements AutoCloseable { + + private final ManagedSubscription owner; + private final SubscriptionBinding candidate; + private final AtomicBoolean committed = new AtomicBoolean(); + + private PreparedSubscription(ManagedSubscription owner, SubscriptionBinding candidate) { + this.owner = owner; + this.candidate = candidate; + } + + private void commit() { + if (committed.compareAndSet(false, true)) { + owner.install(candidate); + } + } + + @Override + public void close() { + if (committed.compareAndSet(false, true)) { + candidate.close(); + } + } + } + + private static final class SubscriptionBinding implements AutoCloseable { + + private final Subscription delegate; + private final GatedListener listener; + private final AtomicBoolean bindingClosed = new AtomicBoolean(); + + private SubscriptionBinding(Subscription delegate, GatedListener listener) { + this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null"); + this.listener = listener; + } + + private void activate() { + listener.active.set(true); + } + + @Override + public void close() { + if (bindingClosed.compareAndSet(false, true)) { + listener.active.set(false); + try { + delegate.close(); + } catch (RuntimeException ignored) { + // Subscription cleanup failure cannot roll back an installed route. + } + } + } + } + + private static final class GatedListener implements Listener { + + private final Listener delegate; + private final AtomicBoolean active = new AtomicBoolean(); + + private GatedListener(Listener delegate) { + this.delegate = delegate; + } + + @Override + public void onMessage(byte[] wireMessage) { + if (active.get()) { + delegate.onMessage(copy(wireMessage)); + } + } + + @Override + public void onDisconnected() { + if (active.get()) { + delegate.onDisconnected(); + } + } + } + + static final class LegacyKeyMaterial implements RedisOwnedPhysicalKeyMaterial { + + private final byte[] encodedKey; + + private LegacyKeyMaterial(String key) { + this.encodedKey = + Objects.requireNonNull(key, "key must be non-null").getBytes(StandardCharsets.UTF_8); + } + + @Override + public byte[] copyEncodedKey() { + return encodedKey.clone(); + } + } + + private static final class RouteState { + + private enum AcquireResult { + ACQUIRED, + RETRY, + SATURATED + } + + private final RedisRoutableCommandRuntime runtime; + private final long generation; + private final RedisRouteIdentity identity; + private final int maximumInFlight; + private final AtomicInteger inFlight = new AtomicInteger(); + private final AtomicBoolean accepting = new AtomicBoolean(true); + private final Object drainMonitor = new Object(); + private final RedisDrainWaiter drainWaiter; + + private RouteState( + RedisRoutableCommandRuntime runtime, + long generation, + int maximumInFlight, + RedisDrainWaiter drainWaiter) { + this( + runtime, + generation, + Objects.requireNonNull( + runtime.routeIdentity(), "runtime route identity must be non-null"), + maximumInFlight, + drainWaiter); + } + + private RouteState( + RedisRoutableCommandRuntime runtime, + long generation, + RedisRouteIdentity identity, + int maximumInFlight, + RedisDrainWaiter drainWaiter) { + this.runtime = runtime; + this.generation = generation; + this.identity = Objects.requireNonNull(identity, "runtime route identity must be non-null"); + this.maximumInFlight = maximumInFlight; + this.drainWaiter = drainWaiter; + } + + private RouteToken token() { + return new RouteToken(generation, identity); + } + + private AcquireResult tryAcquire() { + if (!accepting.get()) { + return AcquireResult.RETRY; + } + while (true) { + int current = inFlight.get(); + if (current >= maximumInFlight) { + return accepting.get() ? AcquireResult.SATURATED : AcquireResult.RETRY; + } + if (inFlight.compareAndSet(current, current + 1)) { + if (accepting.get()) { + return AcquireResult.ACQUIRED; + } + release(); + return AcquireResult.RETRY; + } + } + } + + private void stopAccepting() { + accepting.set(false); + signalIfDrained(); + } + + private void release() { + int remaining = inFlight.decrementAndGet(); + if (remaining < 0) { + throw new IllegalStateException("Redis route lease released more than once"); + } + signalIfDrained(); + } + + private RedisDrainWaiter.Result awaitDrain(Duration timeout) { + return drainWaiter.await(inFlight::get, drainMonitor, timeout); + } + + private void signalIfDrained() { + if (inFlight.get() == 0) { + synchronized (drainMonitor) { + drainMonitor.notifyAll(); + } + } + } + + private int inFlight() { + return inFlight.get(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoutableCommandRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoutableCommandRuntime.java new file mode 100644 index 0000000..eac099f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoutableCommandRuntime.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime; +import java.time.Duration; + +/** Adapter-private command runtime that can be probed before atomic route installation. */ +interface RedisRoutableCommandRuntime + extends RedisBinaryCommands, + RedisPrimitiveCommands, + RedisStructuredCommands, + RedisInvalidationTransport, + RedisRotatableRuntime { + + default RedisRouteIdentity routeIdentity() { + return RedisRouteIdentity.opaqueRuntime(this); + } + + void probe(Duration timeout); + + @Override + default RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + throw new UnsupportedOperationException("Redis primitive dispatch is unsupported"); + } + + @Override + default String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + throw new UnsupportedOperationException("Redis catalog load is unsupported"); + } + + @Override + default RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) { + throw new UnsupportedOperationException("Redis catalog execution is unsupported"); + } + + @Override + default long publish(byte[] channel, byte[] message) { + throw new UnsupportedOperationException("Redis invalidation publish is unsupported"); + } + + @Override + default Subscription subscribe(byte[] channel, Listener listener) { + throw new UnsupportedOperationException("Redis invalidation subscription is unsupported"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRouteIdentity.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRouteIdentity.java new file mode 100644 index 0000000..ce1dc46 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRouteIdentity.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Objects; + +/** + * Adapter-private route identity that supports equality without rendering endpoint material. + * + *

Opaque runtimes compare by object identity. Sentinel runtimes compare by a one-way digest of + * the quorum-approved data endpoint. + */ +final class RedisRouteIdentity { + + private static final String REDACTED_RENDERING = "redis-route-identity[redacted]"; + + private final Object opaqueRuntime; + private final byte[] sentinelEndpointDigest; + + private RedisRouteIdentity(Object opaqueRuntime, byte[] sentinelEndpointDigest) { + this.opaqueRuntime = opaqueRuntime; + this.sentinelEndpointDigest = sentinelEndpointDigest; + } + + static RedisRouteIdentity opaqueRuntime(Object runtime) { + return new RedisRouteIdentity( + Objects.requireNonNull(runtime, "runtime must be non-null"), null); + } + + static RedisRouteIdentity sentinel(RedisSentinelMasterDiscovery.DataEndpoint endpoint) { + Objects.requireNonNull(endpoint, "approved endpoint must be non-null"); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(endpoint.host().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(endpoint.port()).array()); + return new RedisRouteIdentity(null, digest.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("Redis route identity cannot be created"); + } + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof RedisRouteIdentity other)) { + return false; + } + if (opaqueRuntime != null || other.opaqueRuntime != null) { + return opaqueRuntime != null + && other.opaqueRuntime != null + && opaqueRuntime == other.opaqueRuntime; + } + return MessageDigest.isEqual(sentinelEndpointDigest, other.sentinelEndpointDigest); + } + + @Override + public int hashCode() { + return opaqueRuntime == null + ? Arrays.hashCode(sentinelEndpointDigest) + : System.identityHashCode(opaqueRuntime); + } + + @Override + public String toString() { + return REDACTED_RENDERING; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeConnector.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeConnector.java new file mode 100644 index 0000000..8fbf8d6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeConnector.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; + +/** Package-private composition seam for opening one already-validated Redis deployment. */ +@FunctionalInterface +interface RedisRuntimeConnector { + + RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java index e01f14e..1d5e47c 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettings.java @@ -16,7 +16,10 @@ public record RedisRuntimeSettings( String keyHmacSecret, Duration commandTimeout, Duration positiveTtl, + Duration positiveSoftTtl, Duration negativeTtl, + Double ttlJitter, + Duration minimumHardTtl, String namespaceApplication, String namespaceEnvironment, String semanticRegion, @@ -26,6 +29,8 @@ public record RedisRuntimeSettings( private static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30); private static final Duration MAXIMUM_TTL = Duration.ofDays(30); + private static final int MAXIMUM_ENVELOPE_OVERHEAD_BYTES = 1056; + private static final int MAXIMUM_COMMAND_OVERHEAD_BYTES = 4096; @ConstructorBinding public RedisRuntimeSettings { @@ -41,7 +46,11 @@ public record RedisRuntimeSettings( keyHmacSecret = keyHmacSecret == null ? "" : keyHmacSecret; commandTimeout = commandTimeout == null ? Duration.ofSeconds(2) : commandTimeout; positiveTtl = positiveTtl == null ? Duration.ofMinutes(5) : positiveTtl; + positiveSoftTtl = + positiveSoftTtl == null ? positiveTtl.multipliedBy(4).dividedBy(5) : positiveSoftTtl; negativeTtl = negativeTtl == null ? Duration.ofSeconds(60) : negativeTtl; + ttlJitter = ttlJitter == null ? 0.10d : ttlJitter; + minimumHardTtl = minimumHardTtl == null ? Duration.ofSeconds(1) : minimumHardTtl; namespaceApplication = defaultText(namespaceApplication, "ca-skeleton"); namespaceEnvironment = defaultText(namespaceEnvironment, "local"); semanticRegion = defaultText(semanticRegion, "default"); @@ -59,7 +68,19 @@ public record RedisRuntimeSettings( } positive(commandTimeout, MAXIMUM_TIMEOUT, "Redis command timeout"); positive(positiveTtl, MAXIMUM_TTL, "Redis positive TTL"); + positive(positiveSoftTtl, MAXIMUM_TTL, "Redis positive soft TTL"); positive(negativeTtl, MAXIMUM_TTL, "Redis negative TTL"); + positive(minimumHardTtl, MAXIMUM_TTL, "Redis minimum hard TTL"); + if (positiveSoftTtl.compareTo(positiveTtl) > 0) { + throw new IllegalArgumentException("Redis positive soft TTL must not exceed hard TTL"); + } + if (!Double.isFinite(ttlJitter) || ttlJitter < 0.0d || ttlJitter > 0.5d) { + throw new IllegalArgumentException("Redis TTL jitter must be in 0.0..0.5"); + } + if (minimumHardTtl.compareTo(positiveTtl) > 0 || minimumHardTtl.compareTo(negativeTtl) > 0) { + throw new IllegalArgumentException( + "Redis minimum hard TTL must not exceed positive or negative hard TTL"); + } slug(namespaceApplication, "Redis namespace application"); slug(namespaceEnvironment, "Redis namespace environment"); slug(semanticRegion, "Redis semantic region"); @@ -69,17 +90,26 @@ public record RedisRuntimeSettings( if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) { throw new IllegalArgumentException("Redis maximum queued commands must be in 1..4096"); } - if (maximumInFlightBytes < maximumValueBytes + 1024 || maximumInFlightBytes > 268_435_456) { + int maximumCommandBytes = maximumValueBytes + MAXIMUM_COMMAND_OVERHEAD_BYTES; + if (maximumInFlightBytes < maximumCommandBytes || maximumInFlightBytes > 268_435_456) { throw new IllegalArgumentException( - "Redis maximum in-flight bytes must cover one maximum value and be <= 268435456"); + "Redis maximum in-flight bytes must cover one maximum cache command and be <= 268435456"); } - long maximumRetainedCommandBytes = (long) maximumQueuedCommands * (maximumValueBytes + 2048L); + long maximumRetainedCommandBytes = (long) maximumQueuedCommands * maximumCommandBytes; if (maximumRetainedCommandBytes > maximumInFlightBytes) { throw new IllegalArgumentException( "Redis queued-command count and maximum value exceed the in-flight byte bound"); } } + int maximumReadableValueBytes() { + return maximumValueBytes + MAXIMUM_ENVELOPE_OVERHEAD_BYTES; + } + + int maximumCommandBytes() { + return maximumValueBytes + MAXIMUM_COMMAND_OVERHEAD_BYTES; + } + RedisRuntimeSettings( boolean enabled, ClientMode clientMode, @@ -103,7 +133,10 @@ public record RedisRuntimeSettings( keyHmacSecret, commandTimeout, positiveTtl, + positiveTtl == null ? null : positiveTtl.multipliedBy(4).dividedBy(5), negativeTtl, + null, + null, namespaceApplication, namespaceEnvironment, semanticRegion, @@ -112,6 +145,43 @@ public record RedisRuntimeSettings( 16_777_216); } + RedisRuntimeSettings( + boolean enabled, + ClientMode clientMode, + String host, + int port, + String password, + String keyHmacSecret, + Duration commandTimeout, + Duration positiveTtl, + Duration negativeTtl, + String namespaceApplication, + String namespaceEnvironment, + String semanticRegion, + int maximumValueBytes, + int maximumQueuedCommands, + int maximumInFlightBytes) { + this( + enabled, + clientMode, + host, + port, + password, + keyHmacSecret, + commandTimeout, + positiveTtl, + positiveTtl == null ? null : positiveTtl.multipliedBy(4).dividedBy(5), + negativeTtl, + null, + null, + namespaceApplication, + namespaceEnvironment, + semanticRegion, + maximumValueBytes, + maximumQueuedCommands, + maximumInFlightBytes); + } + /** Selects the module-owned Lettuce runtime or an explicitly supplied {@link RedisClient}. */ public enum ClientMode { MANAGED, diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisScriptRecovery.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisScriptRecovery.java new file mode 100644 index 0000000..e80df07 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisScriptRecovery.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Executes a closed-catalog script by digest and performs one bounded cache-miss recovery. + * + *

The only source accepted here is the immutable byte array owned by a catalog descriptor. + * Recovery is exactly {@code SCRIPT LOAD -> EVALSHA}; this type has no dynamic {@code EVAL} + * surface. + */ +final class RedisScriptRecovery { + + private static final HexFormat HEX = HexFormat.of(); + + private RedisScriptRecovery() {} + + static byte[] evalValue( + RedisStructuredCommands commands, RedisCatalogProgramInvocation invocation) { + return executeLogical(commands, invocation).copyValue(); + } + + static byte[] evalReadOnlyValue( + RedisStructuredCommands commands, RedisCatalogProgramInvocation invocation) { + return executeLogical(commands, invocation).copyValue(); + } + + static java.util.List evalMulti( + RedisStructuredCommands commands, RedisCatalogProgramInvocation invocation) { + return executeLogical(commands, invocation).copyFields(); + } + + static RedisCatalogProgramReply executeOnSelectedRuntime( + RedisStructuredCommands commands, RedisCatalogProgramInvocation invocation) { + try { + return commands.executeCatalogProgram(invocation); + } catch (RedisNoScriptException noScript) { + loadExactSource(commands, invocation); + return commands.executeCatalogProgram(invocation); + } + } + + private static RedisCatalogProgramReply executeLogical( + RedisStructuredCommands commands, RedisCatalogProgramInvocation invocation) { + if (commands instanceof RedisRoleCommandRouter router) { + return router.executeCatalogProgramWithRecovery(invocation); + } + return executeOnSelectedRuntime(commands, invocation); + } + + static String sha1(byte[] script) { + try { + return HEX.formatHex(MessageDigest.getInstance("SHA-1").digest(script)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-1 unavailable for Redis script identity", exception); + } + } + + private static void loadExactSource( + RedisStructuredCommands commands, RedisCatalogProgramInvocation invocation) { + String expectedSha1 = sha1(RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)); + String loadedSha1 = commands.loadCatalogProgram(invocation); + if (loadedSha1 == null + || !MessageDigest.isEqual( + expectedSha1.getBytes(StandardCharsets.US_ASCII), + loadedSha1.getBytes(StandardCharsets.US_ASCII))) { + RedisProgramId programId = invocation.programIdOrNull(); + if (programId != null) { + throw new RedisProgramCompatibilityException(programId, ""); + } + throw new IllegalStateException( + "Redis exact program " + invocation.externalId() + " load digest is incompatible"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclProbeCatalog.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclProbeCatalog.java new file mode 100644 index 0000000..3c25953 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclProbeCatalog.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.Collectors; + +/** Closed source catalog for the non-mutating capability ACL-surface readiness program. */ +final class RedisSemanticAclProbeCatalog { + + static final String SCRIPT_RESOURCE = "redis/scripts/semantic-capability-acl-v1.lua"; + private static final byte[] SCRIPT = readResource(); + + static { + RedisSemanticAclScriptContract.validate( + SCRIPT, + Arrays.stream(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.values()) + .collect( + Collectors.toUnmodifiableMap( + capability -> capability, RedisSemanticAclSurface::forCapability))); + } + + private RedisSemanticAclProbeCatalog() {} + + static byte[] scriptBytes() { + return SCRIPT.clone(); + } + + static RedisCatalogProgramInvocation invocation( + RedisSemanticReadinessProbe.AclProbeMaterial material) { + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability capability = + material.capability(); + List keys = material.copyKeys(); + RedisSemanticAclSurface surface = RedisSemanticAclSurface.forCapability(capability); + if (keys.size() != surface.keyNames().size()) { + throw new IllegalArgumentException("Redis semantic ACL key surface is incompatible"); + } + return RedisCatalogProgramInvocation.semanticAclProbe(material); + } + + static String sha256() { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(SCRIPT)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static byte[] readResource() { + try (InputStream input = + RedisSemanticAclProbeCatalog.class.getClassLoader().getResourceAsStream(SCRIPT_RESOURCE)) { + if (input == null) { + throw new IllegalStateException("missing Redis semantic ACL probe resource"); + } + return input.readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("cannot read Redis semantic ACL probe resource", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContract.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContract.java new file mode 100644 index 0000000..0545d9e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContract.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Validates that the checked-in Lua command/key calls match the canonical readiness surface. */ +final class RedisSemanticAclScriptContract { + + private static final Pattern PERMITTED_CALL = + Pattern.compile("permitted\\('([A-Z][A-Z0-9]*)'([^\\n]*)\\)"); + private static final Pattern KEY_POSITION = Pattern.compile("KEYS\\[([1-9][0-9]*)]"); + + private RedisSemanticAclScriptContract() {} + + static void validate( + byte[] scriptBytes, Map expectedSurfaces) { + String script = + new String(Objects.requireNonNull(scriptBytes, "scriptBytes must be non-null"), UTF_8); + Map surfaces = + new EnumMap<>( + Objects.requireNonNull(expectedSurfaces, "expectedSurfaces must be non-null")); + if (surfaces.size() != Capability.values().length) { + throw mismatch(); + } + for (Capability capability : Capability.values()) { + RedisSemanticAclSurface expected = surfaces.get(capability); + if (expected == null + || expected.capability() != capability + || !extract(script, capability, expected.keyNames().size()) + .equals(expected.commandKeyPositions())) { + throw mismatch(); + } + } + } + + private static Map> extract( + String script, Capability capability, int expectedKeyCount) { + String condition = "capability == '" + capability.name() + "' then"; + int conditionStart = script.indexOf(condition); + if (conditionStart < 0) { + throw mismatch(); + } + int bodyStart = conditionStart + condition.length(); + int nextCapability = script.indexOf("\nelseif capability ==", bodyStart); + int finalElse = script.indexOf("\nelse\n return 'INVALID'", bodyStart); + int bodyEnd = + nextCapability >= 0 && (finalElse < 0 || nextCapability < finalElse) + ? nextCapability + : finalElse; + if (bodyEnd < 0) { + throw mismatch(); + } + String body = script.substring(bodyStart, bodyEnd); + if (!body.contains("if #KEYS ~= " + expectedKeyCount + " then return 'INVALID' end")) { + throw mismatch(); + } + + Map> collected = new LinkedHashMap<>(); + Matcher calls = PERMITTED_CALL.matcher(body); + while (calls.find()) { + String command = calls.group(1); + LinkedHashSet positions = + collected.computeIfAbsent(command, ignored -> new LinkedHashSet<>()); + Matcher keys = KEY_POSITION.matcher(calls.group(2)); + while (keys.find()) { + positions.add(Integer.parseInt(keys.group(1))); + } + } + Map> result = new LinkedHashMap<>(); + collected.forEach( + (command, positions) -> { + List sorted = new ArrayList<>(positions); + sorted.sort(Integer::compareTo); + result.put(command, List.copyOf(sorted)); + }); + return Map.copyOf(result); + } + + private static IllegalArgumentException mismatch() { + return new IllegalArgumentException( + "Redis semantic ACL Lua surface does not match its canonical mapping"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclSurface.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclSurface.java new file mode 100644 index 0000000..d3a05f5 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclSurface.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Exact representative-program command-to-key ACL surface used by semantic readiness. */ +record RedisSemanticAclSurface( + Capability capability, + RedisProgramId representativeProgram, + List keyNames, + Map> commandKeyPositions) { + + RedisSemanticAclSurface { + Objects.requireNonNull(capability, "capability must be non-null"); + Objects.requireNonNull(representativeProgram, "representativeProgram must be non-null"); + keyNames = List.copyOf(Objects.requireNonNull(keyNames, "keyNames must be non-null")); + if (keyNames.isEmpty()) { + throw new IllegalArgumentException("Redis semantic ACL surface requires program keys"); + } + int keyCount = keyNames.size(); + Map> copied = new LinkedHashMap<>(); + Objects.requireNonNull(commandKeyPositions, "commandKeyPositions must be non-null") + .forEach( + (command, positions) -> { + if (command == null || !command.matches("[A-Z][A-Z0-9]*")) { + throw new IllegalArgumentException( + "Redis semantic ACL command must be uppercase ASCII"); + } + List safePositions = + List.copyOf(Objects.requireNonNull(positions, "key positions must be non-null")); + if (safePositions.stream() + .anyMatch(position -> position == null || position < 1 || position > keyCount) + || Set.copyOf(safePositions).size() != safePositions.size()) { + throw new IllegalArgumentException( + "Redis semantic ACL key positions must be unique descriptor indexes"); + } + copied.put(command, safePositions); + }); + commandKeyPositions = Map.copyOf(copied); + + RedisProgramDescriptor descriptor = + RedisProgramCatalog.unified().descriptor(representativeProgram); + List descriptorKeyNames = + descriptor.contract().keys().stream().map(RedisProgramContract.Input::name).toList(); + if (!keyNames.equals(descriptorKeyNames) + || !commandKeyPositions.keySet().equals(descriptor.contract().aclCommands())) { + throw new IllegalArgumentException( + "Redis semantic ACL surface must match the representative descriptor"); + } + Set referenced = new java.util.HashSet<>(); + commandKeyPositions.values().forEach(referenced::addAll); + for (int position = 1; position <= keyNames.size(); position++) { + if (!referenced.contains(position)) { + throw new IllegalArgumentException( + "Redis semantic ACL surface must cover every representative key"); + } + } + } + + static RedisSemanticAclSurface forCapability(Capability capability) { + Objects.requireNonNull(capability, "capability must be non-null"); + RedisProgramId program = RedisSemanticProbePlan.representativeProgram(capability); + List keyNames = + RedisProgramCatalog.unified().descriptor(program).contract().keys().stream() + .map(RedisProgramContract.Input::name) + .toList(); + return new RedisSemanticAclSurface( + capability, program, keyNames, commandKeyPositions(capability)); + } + + private static Map> commandKeyPositions(Capability capability) { + return switch (capability) { + case CACHE -> positions(entry("TYPE", 1), entry("SET", 1)); + case RATE_LIMIT -> + positions( + entry("TYPE", 1, 2, 3), + entry("TIME"), + entry("HMGET", 1), + entry("HGET", 2), + entry("HSET", 1, 2), + entry("HDEL", 2), + entry("HLEN", 2), + entry("PEXPIRE", 1, 2, 3), + entry("ZSCORE", 3), + entry("ZADD", 3), + entry("ZREM", 3), + entry("ZCARD", 3), + entry("ZRANGEBYSCORE", 3), + entry("ZPOPMIN", 3)); + case IDEMPOTENCY -> + positions( + entry("TYPE", 1), + entry("TIME"), + entry("HMGET", 1), + entry("HSET", 1), + entry("HDEL", 1), + entry("PEXPIRE", 1)); + case EFFICIENCY_LEASE -> + positions( + entry("TYPE", 1), + entry("TIME"), + entry("HSET", 1), + entry("PEXPIRE", 1), + entry("HMGET", 1), + entry("PTTL", 1), + entry("DEL", 1)); + case SESSION -> + positions( + entry("TIME"), + entry("EXISTS", 1, 2), + entry("HMGET", 1), + entry("HSET", 1), + entry("PEXPIRE", 1)); + }; + } + + @SafeVarargs + private static Map> positions(Map.Entry>... entries) { + Map> result = new LinkedHashMap<>(); + for (Map.Entry> entry : entries) { + result.put(entry.getKey(), entry.getValue()); + } + return result; + } + + private static Map.Entry> entry(String command, Integer... positions) { + List copied = new ArrayList<>(positions.length); + java.util.Collections.addAll(copied, positions); + return Map.entry(command, List.copyOf(copied)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCache.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCache.java new file mode 100644 index 0000000..dc9022b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCache.java @@ -0,0 +1,153 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** Single-flight, bounded-staleness cache for one role's semantic readiness observation. */ +final class RedisSemanticProbeObservationCache { + + private final Duration minimumInterval; + private final Duration maximumStaleness; + private final Clock clock; + private final LongSupplier ticker; + private final Runnable beforeRefreshClaim; + private final Runnable afterRefreshStore; + private final AtomicReference last = new AtomicReference<>(); + private final AtomicBoolean refreshing = new AtomicBoolean(); + + RedisSemanticProbeObservationCache( + Duration minimumInterval, Duration maximumStaleness, Clock clock) { + this(minimumInterval, maximumStaleness, clock, System::nanoTime, () -> {}); + } + + RedisSemanticProbeObservationCache( + Duration minimumInterval, Duration maximumStaleness, Clock clock, LongSupplier ticker) { + this(minimumInterval, maximumStaleness, clock, ticker, () -> {}); + } + + RedisSemanticProbeObservationCache( + Duration minimumInterval, + Duration maximumStaleness, + Clock clock, + LongSupplier ticker, + Runnable beforeRefreshClaim) { + this(minimumInterval, maximumStaleness, clock, ticker, beforeRefreshClaim, () -> {}); + } + + RedisSemanticProbeObservationCache( + Duration minimumInterval, + Duration maximumStaleness, + Clock clock, + LongSupplier ticker, + Runnable beforeRefreshClaim, + Runnable afterRefreshStore) { + this.minimumInterval = requirePositive(minimumInterval, "minimumInterval must be positive"); + this.maximumStaleness = requirePositive(maximumStaleness, "maximumStaleness must be positive"); + if (maximumStaleness.compareTo(minimumInterval) < 0) { + throw new IllegalArgumentException("maximumStaleness must cover minimumInterval"); + } + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.ticker = Objects.requireNonNull(ticker, "ticker must be non-null"); + this.beforeRefreshClaim = + Objects.requireNonNull(beforeRefreshClaim, "beforeRefreshClaim must be non-null"); + this.afterRefreshStore = + Objects.requireNonNull(afterRefreshStore, "afterRefreshStore must be non-null"); + } + + Observation seed(Reason reason) { + StoredObservation seeded = store(reason); + return observation(seeded, Duration.ZERO, false); + } + + Observation observe(Supplier probe) { + Objects.requireNonNull(probe, "probe must be non-null"); + Instant now = clock.instant(); + StoredObservation current = last.get(); + long nowTick = ticker.getAsLong(); + Duration age = age(current, nowTick); + if (current != null && age.compareTo(minimumInterval) < 0) { + return observation(current, age, false); + } + beforeRefreshClaim.run(); + if (refreshing.compareAndSet(false, true)) { + try { + StoredObservation claimedCurrent = last.get(); + long claimedNowTick = ticker.getAsLong(); + Duration claimedAge = age(claimedCurrent, claimedNowTick); + if (claimedCurrent != null && claimedAge.compareTo(minimumInterval) < 0) { + return observation(claimedCurrent, claimedAge, false); + } + StoredObservation refreshed = store(probe.get()); + afterRefreshStore.run(); + return observation(refreshed, Duration.ZERO, false); + } finally { + refreshing.set(false); + } + } + current = last.get(); + if (current == null) { + return new Observation(Reason.SEMANTIC_PROBE_IN_PROGRESS, now, Duration.ZERO, true); + } + nowTick = ticker.getAsLong(); + age = age(current, nowTick); + if (age.compareTo(minimumInterval) < 0) { + return observation(current, age, false); + } + if (age.compareTo(maximumStaleness) <= 0) { + return observation(current, age, true); + } + return new Observation(Reason.SEMANTIC_OBSERVATION_STALE, current.observedAt(), age, true); + } + + private StoredObservation store(Reason reason) { + Instant observedAt = clock.instant(); + StoredObservation stored = + new StoredObservation( + Objects.requireNonNull(reason, "probe reason must be non-null"), + observedAt, + ticker.getAsLong()); + last.set(stored); + return stored; + } + + private static Duration age(StoredObservation observation, long nowTick) { + if (observation == null) { + return Duration.ZERO; + } + long elapsedNanos = nowTick - observation.observedAtTick(); + if (elapsedNanos < 0) { + return Duration.ofNanos(Long.MAX_VALUE); + } + return Duration.ofNanos(elapsedNanos); + } + + private static Observation observation(StoredObservation stored, Duration age, boolean stale) { + return new Observation(stored.reason(), stored.observedAt(), age, stale); + } + + private static Duration requirePositive(Duration value, String message) { + Objects.requireNonNull(value, message); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(message); + } + return value; + } + + record Observation(Reason reason, Instant observedAt, Duration age, boolean stale) { + + Observation { + Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(observedAt, "observedAt must be non-null"); + Objects.requireNonNull(age, "age must be non-null"); + } + } + + private record StoredObservation(Reason reason, Instant observedAt, long observedAtTick) {} +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbePlan.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbePlan.java new file mode 100644 index 0000000..c3fbafa --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbePlan.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import java.time.Duration; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Immutable role-local readiness contract compiled only from selected Redis capabilities. */ +record RedisSemanticProbePlan( + RedisRole role, + Set capabilities, + boolean commonReadWrite, + List representativePrograms) { + + static final String KEY_NAMESPACE_PREFIX = "ca-health:"; + static final String ACL_KEY_PATTERN = "~ca-health:*"; + static final Duration MAXIMUM_TTL = Duration.ofSeconds(5); + + private static final Map REPRESENTATIVE_PROGRAMS = + representativeProgramMap(); + + RedisSemanticProbePlan { + Objects.requireNonNull(role, "role must be non-null"); + capabilities = + Set.copyOf(Objects.requireNonNull(capabilities, "capabilities must be non-null")); + representativePrograms = + List.copyOf( + Objects.requireNonNull( + representativePrograms, "representativePrograms must be non-null")); + if (capabilities.isEmpty() + || !commonReadWrite + || representativePrograms.size() != capabilities.size()) { + throw new IllegalArgumentException( + "Redis semantic probe plan must cover every selected capability"); + } + } + + static RedisSemanticProbePlan forRole(RedisRole role, Set selected) { + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(selected, "selected must be non-null"); + if (selected.isEmpty()) { + throw new IllegalArgumentException( + "Redis semantic probe plan requires at least one selected capability"); + } + EnumSet capabilities = EnumSet.copyOf(selected); + for (Capability capability : capabilities) { + if (!belongsTo(role, capability)) { + throw new IllegalArgumentException("Redis capability does not belong to the selected role"); + } + } + List programs = new ArrayList<>(capabilities.size()); + for (Capability capability : Capability.values()) { + if (capabilities.contains(capability)) { + programs.add(REPRESENTATIVE_PROGRAMS.get(capability)); + } + } + return new RedisSemanticProbePlan(role, capabilities, true, programs); + } + + static RedisProgramId representativeProgram(Capability capability) { + return REPRESENTATIVE_PROGRAMS.get( + Objects.requireNonNull(capability, "capability must be non-null")); + } + + private static boolean belongsTo(RedisRole role, Capability capability) { + return switch (role) { + case CACHE -> capability == Capability.CACHE; + case COORDINATION -> + capability == Capability.RATE_LIMIT + || capability == Capability.IDEMPOTENCY + || capability == Capability.EFFICIENCY_LEASE; + case SESSION -> capability == Capability.SESSION; + }; + } + + private static Map representativeProgramMap() { + EnumMap programs = new EnumMap<>(Capability.class); + programs.put(Capability.CACHE, RedisProgramId.SET_IF_ABSENT_WITH_TTL); + programs.put(Capability.RATE_LIMIT, RedisProgramId.RATE_FIXED_WINDOW_V2); + programs.put(Capability.IDEMPOTENCY, RedisProgramId.IDEMPOTENCY_CLAIM_V1); + programs.put(Capability.EFFICIENCY_LEASE, RedisProgramId.LEASE_ACQUIRE_V1); + programs.put(Capability.SESSION, RedisProgramId.SESSION_CREATE_V1); + return Map.copyOf(programs); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbe.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbe.java new file mode 100644 index 0000000..b7acd21 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbe.java @@ -0,0 +1,459 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** Executes bounded role readiness probes exclusively through the canonical command router. */ +final class RedisSemanticReadinessProbe { + + private static final byte[] PROBE_VALUE = "semantic-ready-v1".getBytes(US_ASCII); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final RedisProgramCatalog catalog; + private final Clock clock; + private final Supplier nonceSupplier; + + RedisSemanticReadinessProbe( + RedisProgramCatalog catalog, Clock clock, Supplier nonceSupplier) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.nonceSupplier = Objects.requireNonNull(nonceSupplier, "nonceSupplier must be non-null"); + } + + static RedisSemanticReadinessProbe system(Clock clock) { + return new RedisSemanticReadinessProbe( + RedisProgramCatalog.unified(), clock, RedisSemanticReadinessProbe::randomNonce); + } + + Reason probe(RedisSemanticProbePlan plan, RedisRoleCommandRouter router) { + return probeResult(plan, router).reason(); + } + + Result probeResult(RedisSemanticProbePlan plan, RedisRoleCommandRouter router) { + Objects.requireNonNull(plan, "plan must be non-null"); + Objects.requireNonNull(router, "router must be non-null"); + if (router.isClosed()) { + return retryable(Reason.ROUTE_CLOSED); + } + try { + router.probe(Duration.ofSeconds(1)); + } catch (RedisCommandFailureException failure) { + return failure.kind() == RedisCommandFailureException.Kind.OVERLOADED + ? retryable(Reason.COMMAND_SATURATED) + : retryable(Reason.COMMAND_UNAVAILABLE); + } catch (RuntimeException failure) { + return retryable(router.isClosed() ? Reason.ROUTE_CLOSED : Reason.COMMAND_UNAVAILABLE); + } + + String nonce = nonceSupplier.get(); + if (nonce == null || !nonce.matches("[A-Za-z0-9_-]{16,64}")) { + return terminal(Reason.SEMANTIC_PROGRAM_FAILED); + } + String base = + RedisSemanticProbePlan.KEY_NAMESPACE_PREFIX + + "{" + + plan.role().name().toLowerCase(java.util.Locale.ROOT) + + "-" + + nonce + + "}:"; + List cleanup = new ArrayList<>(); + Result result; + boolean programStage = false; + boolean cleanupSucceeded; + try { + byte[] readWriteKey = key(base, "rw", cleanup); + RedisPhysicalKey readWritePhysicalKey = + RedisPhysicalKey.owned(new ProbeKeyMaterial(readWriteKey)); + router.set( + readWritePhysicalKey, + RedisBinaryValue.encoded(PROBE_VALUE), + RedisSemanticProbePlan.MAXIMUM_TTL); + byte[] observed = router.get(readWritePhysicalKey); + if (observed == null || !MessageDigest.isEqual(PROBE_VALUE, observed)) { + throw new ReadWriteProbeException(); + } + + int programIndex = 0; + for (RedisProgramId id : plan.representativePrograms()) { + programStage = true; + List programKeys = prepareProgramKeys(id, base, programIndex, router, cleanup); + executeAclSurface(capability(id), programKeys, router); + executeProgram(id, programKeys, router); + programIndex++; + } + result = succeeded(); + } catch (RedisCommandFailureException failure) { + result = classify(failure, programStage); + } catch (ReadWriteProbeException failure) { + result = terminal(Reason.SEMANTIC_READ_WRITE_FAILED); + } catch (SemanticAclDeniedException failure) { + result = terminal(Reason.SEMANTIC_PROGRAM_ACL_DENIED); + } catch (SemanticVersionUnsupportedException failure) { + result = terminal(Reason.SERVER_VERSION_UNSUPPORTED); + } catch (RuntimeException failure) { + result = + terminal( + programStage ? Reason.SEMANTIC_PROGRAM_FAILED : Reason.SEMANTIC_READ_WRITE_FAILED); + } finally { + cleanupSucceeded = cleanup(router, cleanup); + } + if (!cleanupSucceeded && result.reason() == Reason.SEMANTIC_PROBE_SUCCEEDED) { + // A failed best-effort delete remains bounded by the TTL written before every mutation. + result = retryable(Reason.SEMANTIC_READ_WRITE_FAILED); + } + if (result.reason() == Reason.SEMANTIC_PROBE_SUCCEEDED && router.hadRecentCommandFailure()) { + return retryable(Reason.RECENT_COMMAND_FAILURE); + } + return result; + } + + private List prepareProgramKeys( + RedisProgramId id, + String base, + int programIndex, + RedisRoleCommandRouter router, + List cleanup) { + RedisProgramDescriptor descriptor = catalog.descriptor(id); + List keys = new ArrayList<>(descriptor.keyCount()); + for (int keyIndex = 0; keyIndex < descriptor.keyCount(); keyIndex++) { + byte[] key = key(base, "p" + programIndex + "-k" + keyIndex, cleanup); + router.set( + RedisPhysicalKey.owned(new ProbeKeyMaterial(key)), + RedisBinaryValue.encoded(PROBE_VALUE), + RedisSemanticProbePlan.MAXIMUM_TTL); + keys.add(key); + } + return List.copyOf(keys); + } + + private void executeProgram(RedisProgramId id, List keys, RedisRoleCommandRouter router) { + RedisProgramDescriptor descriptor = catalog.descriptor(id); + List arguments = arguments(id); + if (arguments.size() != descriptor.argumentCount()) { + throw new IllegalStateException("Redis semantic program invocation contract is incomplete"); + } + + String expectedStatus = expectedStatus(id); + RedisCatalogProgramInvocation invocation = + catalog.capabilityInvocation(new ProgramInvocation(id, keys, arguments)); + if (id == RedisProgramId.SET_IF_ABSENT_WITH_TTL) { + byte[] result = RedisScriptRecovery.evalValue(router, invocation); + if (!expectedStatus.equals(asciiStatus(result, descriptor))) { + throw new IllegalStateException("Redis semantic scalar program result is incompatible"); + } + return; + } + List result = RedisScriptRecovery.evalMulti(router, invocation); + if (result == null + || result.size() != descriptor.replyFieldCount() + || !expectedStatus.equals(asciiStatus(result.getFirst(), descriptor))) { + throw new IllegalStateException("Redis semantic structured program result is incompatible"); + } + for (byte[] field : result) { + if (field == null || field.length > descriptor.maximumReplyFieldBytes()) { + throw new IllegalStateException("Redis semantic program reply exceeds its catalog bound"); + } + } + } + + private static void executeAclSurface( + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability capability, + List programKeys, + RedisRoleCommandRouter router) { + RedisSemanticAclSurface surface = RedisSemanticAclSurface.forCapability(capability); + if (programKeys.size() != surface.keyNames().size()) { + throw new IllegalStateException( + "Redis semantic ACL keys do not match the representative descriptor"); + } + RedisCatalogProgramInvocation invocation = + RedisSemanticAclProbeCatalog.invocation(new AclProbeMaterial(capability, programKeys)); + byte[] result = RedisScriptRecovery.evalReadOnlyValue(router, invocation); + String status = boundedAscii(result); + if ("ACL_DENIED".equals(status)) { + throw new SemanticAclDeniedException(); + } + if ("VERSION_UNSUPPORTED".equals(status)) { + throw new SemanticVersionUnsupportedException(); + } + if (!"ACL_OK".equals(status)) { + throw new IllegalStateException("Redis semantic ACL program result is incompatible"); + } + } + + private List arguments(RedisProgramId id) { + long now = clock.millis(); + String operation = "operationabcdefghijklmnop"; + String owner = "ownerabcdefghijklmnop"; + String digest = "0".repeat(64); + return switch (id) { + case SET_IF_ABSENT_WITH_TTL -> + ascii( + PROBE_VALUE, Long.toString(RedisSemanticProbePlan.MAXIMUM_TTL.toMillis()), operation); + case RATE_FIXED_WINDOW_V2 -> + ascii( + "2", + "semantic-health-v1", + "1", + "1", + "1000", + "1000", + "0", + "ev1:abcdefghijklmnopqrstuv", + "5000", + "1", + "256"); + case IDEMPOTENCY_CLAIM_V1 -> + ascii("2", digest, owner, operation, "1000", "5000", "health", "health-v1"); + case LEASE_ACQUIRE_V1 -> ascii("1", owner, operation, "5000"); + case SESSION_CREATE_V1 -> + ascii( + "eA==", + "1", + Long.toString(Math.addExact(now, RedisSemanticProbePlan.MAXIMUM_TTL.toMillis())), + Long.toString(now), + Long.toString(RedisSemanticProbePlan.MAXIMUM_TTL.toMillis()), + operation, + digest); + default -> + throw new IllegalArgumentException( + "Redis program is not a semantic readiness representative"); + }; + } + + static final class ProgramInvocation implements RedisCatalogProgramMaterial { + + private final RedisProgramId programId; + private final List keys; + private final List arguments; + + private ProgramInvocation(RedisProgramId programId, List keys, List arguments) { + this.programId = Objects.requireNonNull(programId, "programId must be non-null"); + this.keys = keys.stream().map(byte[]::clone).toList(); + this.arguments = arguments.stream().map(byte[]::clone).toList(); + } + + @Override + public RedisProgramId programId() { + return programId; + } + + @Override + public RedisCatalogProgramInvocation.ReplyShape replyShape() { + return programId == RedisProgramId.SET_IF_ABSENT_WITH_TTL + ? RedisCatalogProgramInvocation.ReplyShape.VALUE + : RedisCatalogProgramInvocation.ReplyShape.MULTI; + } + + @Override + public List copyKeys() { + return keys.stream().map(byte[]::clone).toList(); + } + + @Override + public List copyArguments() { + return arguments.stream().map(byte[]::clone).toList(); + } + } + + static final class AclProbeMaterial { + + private final dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability capability; + private final List keys; + + private AclProbeMaterial( + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability capability, + List keys) { + this.capability = Objects.requireNonNull(capability, "capability must be non-null"); + this.keys = + Objects.requireNonNull(keys, "keys must be non-null").stream() + .map(byte[]::clone) + .toList(); + } + + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability capability() { + return capability; + } + + List copyKeys() { + return keys.stream().map(byte[]::clone).toList(); + } + } + + private static Result classify(RedisCommandFailureException failure, boolean programStage) { + return switch (failure.kind()) { + case OVERLOADED -> retryable(Reason.COMMAND_SATURATED); + case ACL_DENIED -> + terminal( + programStage + ? Reason.SEMANTIC_PROGRAM_ACL_DENIED + : Reason.SEMANTIC_READ_WRITE_FAILED); + case UNAVAILABLE -> + retryable( + programStage ? Reason.SEMANTIC_PROGRAM_FAILED : Reason.SEMANTIC_READ_WRITE_FAILED); + }; + } + + private static Result succeeded() { + return new Result(Reason.SEMANTIC_PROBE_SUCCEEDED, Disposition.SUCCEEDED); + } + + private static Result retryable(Reason reason) { + return new Result(reason, Disposition.RETRYABLE_TRANSPORT); + } + + private static Result terminal(Reason reason) { + return new Result(reason, Disposition.TERMINAL_CONTRACT); + } + + private static boolean cleanup(RedisRoleCommandRouter router, List keys) { + boolean succeeded = true; + for (byte[] key : keys) { + try { + router.delete(RedisPhysicalKey.owned(new ProbeKeyMaterial(key))); + } catch (RuntimeException ignored) { + succeeded = false; + } + } + return succeeded; + } + + private static byte[] key(String base, String suffix, List cleanup) { + byte[] key = (base + suffix).getBytes(UTF_8); + if (key.length > 512) { + throw new IllegalStateException("Redis semantic probe key exceeds its bound"); + } + cleanup.add(key); + return key; + } + + private static List ascii(Object... values) { + List result = new ArrayList<>(values.length); + for (Object value : values) { + result.add( + value instanceof byte[] bytes ? bytes.clone() : value.toString().getBytes(US_ASCII)); + } + return List.copyOf(result); + } + + private static String asciiStatus(byte[] result, RedisProgramDescriptor descriptor) { + if (result == null || result.length < 1 || result.length > 128) { + throw new IllegalStateException("Redis semantic program returned an invalid status"); + } + for (byte value : result) { + if (value < 0x20 || value > 0x7e) { + throw new IllegalStateException("Redis semantic program status is not bounded ASCII"); + } + } + String status = new String(result, US_ASCII); + if (!descriptor.statuses().contains(status)) { + throw new IllegalStateException("Redis semantic program returned an unknown status"); + } + return status; + } + + private static String boundedAscii(byte[] result) { + if (result == null || result.length < 1 || result.length > 32) { + throw new IllegalStateException("Redis semantic ACL program returned an invalid status"); + } + for (byte value : result) { + if (value < 0x20 || value > 0x7e) { + throw new IllegalStateException("Redis semantic ACL status is not bounded ASCII"); + } + } + return new String(result, US_ASCII); + } + + private static String expectedStatus(RedisProgramId id) { + return switch (id) { + case SET_IF_ABSENT_WITH_TTL -> "EXISTS"; + case RATE_FIXED_WINDOW_V2, IDEMPOTENCY_CLAIM_V1, LEASE_ACQUIRE_V1 -> "STATE_INCOMPATIBLE"; + case SESSION_CREATE_V1 -> "TOMBSTONED"; + default -> + throw new IllegalArgumentException( + "Redis program is not a semantic readiness representative"); + }; + } + + private static dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability capability( + RedisProgramId id) { + return switch (id) { + case SET_IF_ABSENT_WITH_TTL -> + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.CACHE; + case RATE_FIXED_WINDOW_V2 -> + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.RATE_LIMIT; + case IDEMPOTENCY_CLAIM_V1 -> + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.IDEMPOTENCY; + case LEASE_ACQUIRE_V1 -> + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.EFFICIENCY_LEASE; + case SESSION_CREATE_V1 -> + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.SESSION; + default -> + throw new IllegalArgumentException( + "Redis program is not a semantic readiness representative"); + }; + } + + private static String randomNonce() { + byte[] entropy = new byte[16]; + RANDOM.nextBytes(entropy); + try { + return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy); + } finally { + java.util.Arrays.fill(entropy, (byte) 0); + } + } + + enum Disposition { + SUCCEEDED, + RETRYABLE_TRANSPORT, + TERMINAL_CONTRACT + } + + record Result(Reason reason, Disposition disposition) { + + Result { + Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(disposition, "disposition must be non-null"); + } + } + + private static final class ReadWriteProbeException extends RuntimeException { + + private static final long serialVersionUID = 1L; + } + + private static final class SemanticAclDeniedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + } + + private static final class SemanticVersionUnsupportedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + } + + static final class ProbeKeyMaterial implements RedisOwnedPhysicalKeyMaterial { + + private final byte[] encodedKey; + + private ProbeKeyMaterial(byte[] encodedKey) { + this.encodedKey = Objects.requireNonNull(encodedKey, "encodedKey must be non-null").clone(); + } + + @Override + public byte[] copyEncodedKey() { + return encodedKey.clone(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveredRoute.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveredRoute.java new file mode 100644 index 0000000..14e908a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveredRoute.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; + +/** Adapter-private quorum result whose rendering never exposes route material. */ +final class RedisSentinelDiscoveredRoute { + + private static final String REDACTED_RENDERING = "redis-sentinel-discovered-route[redacted]"; + + private final RedisSentinelMasterDiscovery.DataEndpoint endpoint; + private final RedisRouteIdentity identity; + + private RedisSentinelDiscoveredRoute(RedisSentinelMasterDiscovery.DataEndpoint endpoint) { + this.endpoint = Objects.requireNonNull(endpoint, "quorum-approved endpoint must be non-null"); + this.identity = RedisRouteIdentity.sentinel(endpoint); + } + + static RedisSentinelDiscoveredRoute fromQuorum( + RedisSentinelMasterDiscovery.DataEndpoint endpoint) { + return new RedisSentinelDiscoveredRoute(endpoint); + } + + RedisSentinelMasterDiscovery.DataEndpoint endpoint() { + return endpoint; + } + + RedisRouteIdentity identity() { + return identity; + } + + @Override + public String toString() { + return REDACTED_RENDERING; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClient.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClient.java new file mode 100644 index 0000000..2c88d83 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClient.java @@ -0,0 +1,325 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulConnection; +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.sentinel.api.StatefulRedisSentinelConnection; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.time.Duration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.LongSupplier; + +/** Queries independent Sentinel endpoints once and returns only a quorum-approved data endpoint. */ +final class RedisSentinelDiscoveryClient { + + private final RedisDeploymentSettings.Sentinel deployment; + private final RedisLettuceUris.SentinelDiscovery uris; + private final RedisClientRuntimeSettings settings; + private final ClientOptions sentinelOptions; + private final DiscoveryTransport transport; + + RedisSentinelDiscoveryClient( + RedisDeploymentSettings.Sentinel deployment, + RedisLettuceUris.SentinelDiscovery uris, + RedisClientRuntimeSettings settings, + ClientOptions sentinelOptions) { + this(deployment, uris, settings, sentinelOptions, new LettuceDiscoveryTransport()); + } + + RedisSentinelDiscoveryClient( + RedisDeploymentSettings.Sentinel deployment, + RedisLettuceUris.SentinelDiscovery uris, + RedisClientRuntimeSettings settings, + ClientOptions sentinelOptions, + DiscoveryTransport transport) { + this.deployment = Objects.requireNonNull(deployment, "deployment must be non-null"); + this.uris = Objects.requireNonNull(uris, "uris must be non-null"); + this.settings = Objects.requireNonNull(settings, "settings must be non-null"); + this.sentinelOptions = + Objects.requireNonNull(sentinelOptions, "sentinelOptions must be non-null"); + this.transport = Objects.requireNonNull(transport, "transport must be non-null"); + } + + RedisSentinelMasterDiscovery.DataEndpoint discover() { + try { + Map uriByEndpoint = uriByEndpoint(); + List sentinelEndpoints = + uriByEndpoint.keySet().stream().toList(); + Set allowedDataEndpoints = allowedDataEndpoints(); + RedisSentinelMasterDiscovery.DataEndpoint discovered = + RedisSentinelMasterDiscovery.discover( + sentinelEndpoints, + deployment.masterName(), + allowedDataEndpoints, + (endpoint, masterName) -> query(uriByEndpoint.get(endpoint), masterName)); + return discovered; + } catch (RedisSentinelMasterDiscovery.DiscoveryFailedException exception) { + throw exception; + } catch (RuntimeException exception) { + throw RedisSentinelMasterDiscovery.failure(); + } + } + + private Map uriByEndpoint() { + List configuredEndpoints = deployment.sentinelEndpoints(); + List discoveryUris = uris.discoveryUris(); + if (configuredEndpoints.size() != discoveryUris.size()) { + throw RedisSentinelMasterDiscovery.failure(); + } + Map result = new LinkedHashMap<>(); + for (int index = 0; index < configuredEndpoints.size(); index++) { + RedisDeploymentSettings.Endpoint endpoint = configuredEndpoints.get(index); + RedisSentinelMasterDiscovery.SentinelEndpoint sentinelEndpoint = + new RedisSentinelMasterDiscovery.SentinelEndpoint(endpoint.host(), endpoint.port()); + RedisURI discoveryUri = discoveryUris.get(index); + if (!RedisSentinelMasterDiscovery.sameEndpoint( + sentinelEndpoint, discoveryUri.getHost(), discoveryUri.getPort())) { + throw RedisSentinelMasterDiscovery.failure(); + } + if (result.put(sentinelEndpoint, discoveryUri) != null) { + throw RedisSentinelMasterDiscovery.failure(); + } + } + return result; + } + + private Set allowedDataEndpoints() { + Set result = new HashSet<>(); + for (RedisDeploymentSettings.Endpoint endpoint : deployment.dataEndpoints()) { + result.add(new RedisSentinelMasterDiscovery.DataEndpoint(endpoint.host(), endpoint.port())); + } + return result; + } + + private RedisSentinelMasterDiscovery.MasterObservation query(RedisURI uri, String masterName) + throws Exception { + if (uri == null) { + throw RedisSentinelMasterDiscovery.failure(); + } + try (DiscoveryHandle handle = transport.connect(uri, sentinelOptions, settings)) { + SocketAddress address = handle.getMasterAddrByName(masterName); + if (!(address instanceof InetSocketAddress inetSocketAddress)) { + throw RedisSentinelMasterDiscovery.failure(); + } + return new RedisSentinelMasterDiscovery.MasterObservation( + inetSocketAddress.getHostString(), Integer.toString(inetSocketAddress.getPort())); + } + } + + interface DiscoveryTransport { + + DiscoveryHandle connect( + RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) throws Exception; + } + + interface DiscoveryHandle extends AutoCloseable { + + SocketAddress getMasterAddrByName(String masterName) throws Exception; + + @Override + void close() throws Exception; + } + + @FunctionalInterface + interface RedisClientFactory { + + RedisClient create(RedisURI uri); + } + + static final class LettuceDiscoveryTransport implements DiscoveryTransport { + + private final RedisClientFactory clientFactory; + private final LongSupplier nanoTime; + + LettuceDiscoveryTransport() { + this(RedisClient::create, System::nanoTime); + } + + LettuceDiscoveryTransport(RedisClientFactory clientFactory) { + this(clientFactory, System::nanoTime); + } + + LettuceDiscoveryTransport(RedisClientFactory clientFactory, LongSupplier nanoTime) { + this.clientFactory = Objects.requireNonNull(clientFactory, "clientFactory must be non-null"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must be non-null"); + } + + @Override + public DiscoveryHandle connect( + RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) throws Exception { + RedisClient client = clientFactory.create(uri); + StatefulRedisSentinelConnection connection = null; + try { + client.setOptions(options); + long deadline = deadline(settings.overallTimeout(), nanoTime); + connection = + await( + client.connectSentinelAsync(StringCodec.UTF8, uri), + boundedByRemaining(settings.acquireTimeout(), deadline, nanoTime)); + connection.setTimeout(settings.commandTimeout()); + await( + connection.async().ping(), + boundedByRemaining(settings.commandTimeout(), deadline, nanoTime)); + return new LettuceDiscoveryHandle( + client, connection, settings.shutdownTimeout(), deadline, settings, nanoTime); + } catch (InterruptedException exception) { + cleanupAfterFailure(client, connection, settings.shutdownTimeout(), exception, nanoTime); + Thread.currentThread().interrupt(); + throw exception; + } catch (RuntimeException exception) { + cleanupAfterFailure(client, connection, settings.shutdownTimeout(), null, nanoTime); + throw new IllegalStateException("Redis Sentinel discovery transport failed"); + } + } + } + + private static final class LettuceDiscoveryHandle implements DiscoveryHandle { + + private final RedisClient client; + private final StatefulRedisSentinelConnection connection; + private final Duration shutdownTimeout; + private final long deadline; + private final RedisClientRuntimeSettings settings; + private final LongSupplier nanoTime; + private boolean closed; + + private LettuceDiscoveryHandle( + RedisClient client, + StatefulRedisSentinelConnection connection, + Duration shutdownTimeout, + long deadline, + RedisClientRuntimeSettings settings, + LongSupplier nanoTime) { + this.client = client; + this.connection = connection; + this.shutdownTimeout = shutdownTimeout; + this.deadline = deadline; + this.settings = settings; + this.nanoTime = nanoTime; + } + + @Override + public SocketAddress getMasterAddrByName(String masterName) throws Exception { + return await( + connection.async().getMasterAddrByName(masterName), + boundedByRemaining(settings.commandTimeout(), deadline, nanoTime)); + } + + @Override + public void close() throws Exception { + if (!closed) { + closed = true; + closeResources(client, connection, shutdownTimeout, nanoTime); + } + } + } + + private static long deadline(Duration timeout, LongSupplier nanoTime) { + return nanoTime.getAsLong() + timeout.toNanos(); + } + + private static Duration boundedByRemaining( + Duration timeout, long deadline, LongSupplier nanoTime) { + long remaining = deadline - nanoTime.getAsLong(); + if (remaining <= 0) { + throw new IllegalStateException("Redis Sentinel discovery deadline expired"); + } + Duration remainingTimeout = Duration.ofNanos(remaining); + return timeout.compareTo(remainingTimeout) < 0 ? timeout : remainingTimeout; + } + + private static T await(Future future, Duration timeout) throws InterruptedException { + try { + return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS); + } catch (InterruptedException exception) { + future.cancel(true); + throw exception; + } catch (TimeoutException exception) { + future.cancel(true); + throw new IllegalStateException("Redis Sentinel discovery timed out"); + } catch (ExecutionException exception) { + throw new IllegalStateException("Redis Sentinel discovery command failed"); + } + } + + private static void cleanupAfterFailure( + RedisClient client, + StatefulConnection connection, + Duration shutdownTimeout, + InterruptedException originalInterruption, + LongSupplier nanoTime) + throws InterruptedException { + try { + closeResources(client, connection, shutdownTimeout, nanoTime); + } catch (InterruptedException cleanupInterruption) { + clearInterruptForCleanup(); + if (originalInterruption == null) { + throw cleanupInterruption; + } + } catch (RuntimeException ignored) { + // The initiating connection/query failure remains the only externally observable detail. + } + } + + private static void closeResources( + RedisClient client, + StatefulConnection connection, + Duration shutdownTimeout, + LongSupplier nanoTime) + throws InterruptedException { + long cleanupDeadline = deadline(shutdownTimeout, nanoTime); + RuntimeException closeFailure = null; + boolean interrupted = false; + if (connection != null) { + try { + await(connection.closeAsync(), remainingOrZero(cleanupDeadline, nanoTime)); + } catch (InterruptedException exception) { + interrupted = true; + clearInterruptForCleanup(); + } catch (RuntimeException exception) { + closeFailure = exception; + } + } + try { + Duration shutdownBudget = remainingOrZero(cleanupDeadline, nanoTime); + await( + client.shutdownAsync(0, shutdownBudget.toNanos(), TimeUnit.NANOSECONDS), + remainingOrZero(cleanupDeadline, nanoTime)); + } catch (InterruptedException exception) { + interrupted = true; + clearInterruptForCleanup(); + } catch (RuntimeException exception) { + closeFailure = exception; + } + if (interrupted) { + Thread.currentThread().interrupt(); + throw new InterruptedException("Redis Sentinel discovery close interrupted"); + } + if (closeFailure != null) { + throw new IllegalStateException("Redis Sentinel discovery close failed"); + } + } + + private static Duration remainingOrZero(long deadline, LongSupplier nanoTime) { + long remaining = deadline - nanoTime.getAsLong(); + return remaining > 0 ? Duration.ofNanos(remaining) : Duration.ZERO; + } + + private static void clearInterruptForCleanup() { + Thread.interrupted(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinator.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinator.java new file mode 100644 index 0000000..561c7c1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinator.java @@ -0,0 +1,334 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.time.Duration; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Bounded, role-local single-flight Sentinel discovery and conditional route installation. */ +final class RedisSentinelFailoverCoordinator implements AutoCloseable { + + private static final String WORKER_NAME = "redis-sentinel-refresh"; + + @FunctionalInterface + interface CandidateQualifier { + + CandidateQualification qualify(RedisRole role, RedisRoutableCommandRuntime candidate); + } + + enum CandidateQualification { + ACCEPTED, + REJECTED + } + + @FunctionalInterface + interface InstallationObserver { + + void installed(RedisRole role, RedisRoleCommandRouter.SwapResult result); + } + + @FunctionalInterface + interface WorkerFactory { + + RedisSentinelRefreshWorker create(int capacity, String threadName); + } + + private final Object lifecycleMonitor = new Object(); + private final Map deployments; + private final Map routers; + private final Map states; + private final RedisSentinelRuntimeConnector connector; + private final CandidateQualifier qualifier; + private final InstallationObserver installationObserver; + private final RedisSentinelRefreshWorker worker; + private final Duration probeTimeout; + private final Duration drainTimeout; + private final Duration shutdownTimeout; + private final List recurringTasks; + private boolean closed; + + RedisSentinelFailoverCoordinator( + Map deployments, + Map routers, + RedisSentinelRuntimeConnector connector, + CandidateQualifier qualifier, + InstallationObserver installationObserver, + Duration probeTimeout, + Duration drainTimeout, + Duration refreshPeriod, + Duration shutdownTimeout, + WorkerFactory workerFactory) { + Objects.requireNonNull(deployments, "deployments must be non-null"); + Objects.requireNonNull(routers, "routers must be non-null"); + if (deployments.isEmpty()) { + throw new IllegalArgumentException( + "Redis Sentinel failover coordinator requires an active Sentinel role"); + } + this.deployments = Map.copyOf(deployments); + EnumMap selectedRouters = new EnumMap<>(RedisRole.class); + EnumMap selectedStates = new EnumMap<>(RedisRole.class); + this.deployments.forEach( + (role, ignored) -> { + RedisRoleCommandRouter router = + Objects.requireNonNull(routers.get(role), "Sentinel role router must be non-null"); + selectedRouters.put(role, router); + selectedStates.put(role, new RoleRefreshState()); + }); + this.routers = Map.copyOf(selectedRouters); + this.states = Map.copyOf(selectedStates); + this.connector = Objects.requireNonNull(connector, "connector must be non-null"); + this.qualifier = Objects.requireNonNull(qualifier, "qualifier must be non-null"); + this.installationObserver = + Objects.requireNonNull(installationObserver, "installationObserver must be non-null"); + this.probeTimeout = positive(probeTimeout, "Redis Sentinel candidate probe timeout"); + this.drainTimeout = positive(drainTimeout, "Redis Sentinel route drain timeout"); + positive(refreshPeriod, "Redis Sentinel discovery refresh period"); + this.shutdownTimeout = positive(shutdownTimeout, "Redis Sentinel worker shutdown timeout"); + this.worker = + Objects.requireNonNull( + Objects.requireNonNull(workerFactory, "workerFactory must be non-null") + .create(this.deployments.size(), WORKER_NAME), + "worker must be non-null"); + List scheduled = + new ArrayList<>(this.deployments.size()); + try { + this.deployments + .keySet() + .forEach( + role -> + scheduled.add( + worker.scheduleWithFixedDelay( + () -> requestScheduledRefresh(role), refreshPeriod))); + this.recurringTasks = List.copyOf(scheduled); + } catch (RuntimeException failure) { + scheduled.forEach(RedisSentinelRefreshWorker.Cancellable::cancel); + worker.shutdown(this.shutdownTimeout); + throw failure; + } + } + + void requestRecovery(RedisRole role, RedisRoleCommandRouter.RouteToken failedRoute) { + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(failedRoute, "failedRoute must be non-null"); + RedisRoleCommandRouter router = routers.get(role); + if (router == null || !isCurrent(router, failedRoute)) { + return; + } + enqueue(role, failedRoute); + } + + boolean isClosed() { + synchronized (lifecycleMonitor) { + return closed; + } + } + + @Override + public void close() { + synchronized (lifecycleMonitor) { + if (closed) { + return; + } + closed = true; + } + recurringTasks.forEach(RedisSentinelRefreshWorker.Cancellable::cancel); + worker.shutdown(shutdownTimeout); + } + + private void requestScheduledRefresh(RedisRole role) { + enqueue(role, null); + } + + private void enqueue(RedisRole role, RedisRoleCommandRouter.RouteToken failedRoute) { + RoleRefreshState state = states.get(role); + if (state == null) { + return; + } + synchronized (lifecycleMonitor) { + if (closed) { + return; + } + synchronized (state) { + if (state.running) { + if (state.followUp) { + state.followUpFailedRoute = merge(state.followUpFailedRoute, failedRoute); + } else { + state.followUp = true; + state.followUpFailedRoute = failedRoute; + } + return; + } + if (state.queued) { + state.queuedFailedRoute = merge(state.queuedFailedRoute, failedRoute); + return; + } + state.queued = true; + state.queuedFailedRoute = failedRoute; + if (!worker.execute(() -> runQueued(role, state))) { + state.queued = false; + state.queuedFailedRoute = null; + } + } + } + } + + private void runQueued(RedisRole role, RoleRefreshState state) { + RedisRoleCommandRouter.RouteToken failedRoute; + synchronized (state) { + if (!state.queued) { + return; + } + state.queued = false; + state.running = true; + failedRoute = state.queuedFailedRoute; + state.queuedFailedRoute = null; + } + try { + refresh(role, failedRoute); + } catch (RuntimeException ignored) { + // Provider failures are contained so recurring refresh remains live and sanitized. + } finally { + scheduleFollowUp(role, state); + } + } + + private void refresh(RedisRole role, RedisRoleCommandRouter.RouteToken failedRoute) { + if (isClosed()) { + return; + } + RedisRoleCommandRouter router = routers.get(role); + RedisRoleCommandRouter.RouteToken captured = currentToken(router); + if (captured == null || (failedRoute != null && !failedRoute.equals(captured))) { + return; + } + RedisSentinelDiscoveredRoute discovered = connector.discover(deployments.get(role)); + if (discovered == null || isClosed()) { + return; + } + RedisRoleCommandRouter.RouteToken current = currentToken(router); + if (!captured.equals(current) || discovered.identity().equals(current.identity())) { + return; + } + RedisRoutableCommandRuntime candidate = connector.connect(deployments.get(role), discovered); + if (candidate == null) { + return; + } + current = currentToken(router); + if (isClosed() || !captured.equals(current)) { + closeQuietly(candidate); + return; + } + CandidateQualification qualification; + try { + qualification = qualifier.qualify(role, candidate); + } catch (RuntimeException ignored) { + if (!router.ownsRuntime(candidate)) { + closeQuietly(candidate); + } + return; + } + if (qualification != CandidateQualification.ACCEPTED) { + if (!router.ownsRuntime(candidate)) { + closeQuietly(candidate); + } + return; + } + current = currentToken(router); + if (isClosed() || !captured.equals(current)) { + closeQuietly(candidate); + return; + } + synchronized (lifecycleMonitor) { + if (closed) { + closeQuietly(candidate); + return; + } + RedisRoleCommandRouter.SwapResult result; + try { + result = router.swapIfGeneration(captured, candidate, probeTimeout, drainTimeout); + } catch (RuntimeException ignored) { + if (!router.ownsRuntime(candidate)) { + closeQuietly(candidate); + } + return; + } + try { + installationObserver.installed(role, result); + } catch (RuntimeException ignored) { + // Observation cannot alter the installed route or expose provider detail. + } + } + } + + private void scheduleFollowUp(RedisRole role, RoleRefreshState state) { + synchronized (lifecycleMonitor) { + synchronized (state) { + state.running = false; + if (closed || !state.followUp) { + state.followUp = false; + state.followUpFailedRoute = null; + return; + } + state.followUp = false; + state.queued = true; + state.queuedFailedRoute = state.followUpFailedRoute; + state.followUpFailedRoute = null; + if (!worker.execute(() -> runQueued(role, state))) { + state.queued = false; + state.queuedFailedRoute = null; + } + } + } + } + + private static RedisRoleCommandRouter.RouteToken merge( + RedisRoleCommandRouter.RouteToken existing, RedisRoleCommandRouter.RouteToken requested) { + if (existing == null || requested == null) { + return null; + } + return requested; + } + + private static boolean isCurrent( + RedisRoleCommandRouter router, RedisRoleCommandRouter.RouteToken expected) { + RedisRoleCommandRouter.RouteToken current = currentToken(router); + return expected.equals(current); + } + + private static RedisRoleCommandRouter.RouteToken currentToken(RedisRoleCommandRouter router) { + try { + return router.routeToken(); + } catch (RuntimeException ignored) { + return null; + } + } + + private static void closeQuietly(RedisRoutableCommandRuntime runtime) { + try { + runtime.close(); + } catch (RuntimeException ignored) { + // Candidate cleanup cannot expose provider or route material. + } + } + + private static Duration positive(Duration value, String label) { + Objects.requireNonNull(value, label + " must be non-null"); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(label + " must be positive"); + } + return value; + } + + private static final class RoleRefreshState { + + private boolean queued; + private boolean running; + private boolean followUp; + private RedisRoleCommandRouter.RouteToken queuedFailedRoute; + private RedisRoleCommandRouter.RouteToken followUpFailedRoute; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java new file mode 100644 index 0000000..f720f64 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscovery.java @@ -0,0 +1,362 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Bounded, network-independent quorum policy for Redis Sentinel master discovery. */ +final class RedisSentinelMasterDiscovery { + + private static final String FAILURE_MESSAGE = "Redis Sentinel master discovery failed"; + + private RedisSentinelMasterDiscovery() {} + + static DataEndpoint discover( + List sentinelEndpoints, + String masterName, + Set expectedDataEndpoints, + SentinelQuery query) { + if (!validAttempt(sentinelEndpoints, masterName, expectedDataEndpoints, query)) { + throw failure(); + } + + Set normalizedSentinels = normalizedSentinels(sentinelEndpoints); + Set allowedEndpoints = normalizedAllowlist(expectedDataEndpoints); + if (normalizedSentinels == null || allowedEndpoints == null) { + throw failure(); + } + + Map observations = new HashMap<>(); + for (SentinelEndpoint sentinelEndpoint : sentinelEndpoints) { + try { + DataEndpoint candidate = normalizedCandidate(query.query(sentinelEndpoint, masterName)); + if (candidate != null && allowedEndpoints.contains(candidate)) { + observations.merge(candidate, 1, Integer::sum); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw failure(); + } catch (Exception ignored) { + // A failed Sentinel is only usable as an absent observation in this bounded attempt. + } + } + + return observations.entrySet().stream() + .filter(entry -> entry.getValue() >= 2) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(RedisSentinelMasterDiscovery::failure); + } + + private static boolean validAttempt( + List sentinelEndpoints, + String masterName, + Set expectedDataEndpoints, + SentinelQuery query) { + return sentinelEndpoints != null + && sentinelEndpoints.size() == 3 + && sentinelEndpoints.stream().noneMatch(endpoint -> endpoint == null) + && masterName != null + && !masterName.isBlank() + && expectedDataEndpoints != null + && !expectedDataEndpoints.isEmpty() + && query != null; + } + + private static Set normalizedSentinels( + List sentinelEndpoints) { + Set normalized = new HashSet<>(); + for (SentinelEndpoint endpoint : sentinelEndpoints) { + DataEndpoint normalizedEndpoint = normalizedEndpoint(endpoint.host(), endpoint.port()); + if (normalizedEndpoint == null || !normalized.add(normalizedEndpoint)) { + return null; + } + } + return normalized; + } + + private static Set normalizedAllowlist(Set expectedDataEndpoints) { + Set normalized = new HashSet<>(); + for (DataEndpoint endpoint : expectedDataEndpoints) { + if (endpoint == null) { + return null; + } + DataEndpoint normalizedEndpoint = normalizedEndpoint(endpoint.host(), endpoint.port()); + if (normalizedEndpoint == null) { + return null; + } + normalized.add(normalizedEndpoint); + } + return normalized; + } + + private static DataEndpoint normalizedCandidate(MasterObservation observation) { + if (observation == null) { + return null; + } + Integer port = parsePort(observation.port()); + if (port == null) { + return null; + } + return normalizedEndpoint(observation.host(), port); + } + + private static DataEndpoint normalizedEndpoint(String rawHost, int port) { + String host = normalizedHost(rawHost); + if (host == null || port < 1 || port > 65535) { + return null; + } + return new DataEndpoint(host, port); + } + + static boolean sameEndpoint(SentinelEndpoint configured, String rawHost, int port) { + if (configured == null) { + return false; + } + DataEndpoint expected = normalizedEndpoint(configured.host(), configured.port()); + DataEndpoint actual = normalizedEndpoint(rawHost, port); + return expected != null && expected.equals(actual); + } + + private static Integer parsePort(String rawPort) { + if (rawPort == null || rawPort.isEmpty()) { + return null; + } + int value = 0; + for (int index = 0; index < rawPort.length(); index++) { + char character = rawPort.charAt(index); + if (character < '0' || character > '9') { + return null; + } + value = value * 10 + (character - '0'); + if (value > 65535) { + return null; + } + } + return value == 0 ? null : value; + } + + private static String normalizedHost(String rawHost) { + if (rawHost == null || rawHost.isBlank() || !rawHost.equals(rawHost.strip())) { + return null; + } + + StringBuilder normalized = new StringBuilder(rawHost.length()); + for (int index = 0; index < rawHost.length(); index++) { + char character = rawHost.charAt(index); + if (character > 0x7f) { + return null; + } + normalized.append(character >= 'A' && character <= 'Z' ? (char) (character + 32) : character); + } + + String host = normalized.toString(); + if (host.indexOf(':') >= 0) { + int[] ipv6 = parseIpv6(host); + return ipv6 == null || semanticIpv6(ipv6) ? null : host; + } + if (numericDotted(host)) { + int[] ipv4 = parseIpv4(host); + return ipv4 == null || semanticIpv4(ipv4) ? null : host; + } + return validDnsName(host) ? host : null; + } + + private static boolean validDnsName(String host) { + if (host.length() > 253 || host.equals("localhost") || host.endsWith(".localhost")) { + return false; + } + String[] labels = host.split("\\.", -1); + for (String label : labels) { + if (label.isEmpty() || label.length() > 63 || !alphanumeric(label.charAt(0))) { + return false; + } + for (int index = 1; index < label.length(); index++) { + char character = label.charAt(index); + if (!alphanumeric(character) && character != '-') { + return false; + } + } + if (!alphanumeric(label.charAt(label.length() - 1))) { + return false; + } + } + return true; + } + + private static boolean alphanumeric(char character) { + return (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9'); + } + + private static boolean numericDotted(String host) { + boolean dot = false; + for (int index = 0; index < host.length(); index++) { + char character = host.charAt(index); + if (character == '.') { + dot = true; + } else if (character < '0' || character > '9') { + return false; + } + } + return dot; + } + + private static int[] parseIpv4(String host) { + String[] octets = host.split("\\.", -1); + if (octets.length != 4) { + return null; + } + + int[] parsed = new int[4]; + for (int index = 0; index < octets.length; index++) { + String octet = octets[index]; + if (octet.isEmpty() || octet.length() > 3 || (octet.length() > 1 && octet.charAt(0) == '0')) { + return null; + } + int value = 0; + for (int characterIndex = 0; characterIndex < octet.length(); characterIndex++) { + char character = octet.charAt(characterIndex); + if (character < '0' || character > '9') { + return null; + } + value = value * 10 + (character - '0'); + } + if (value > 255) { + return null; + } + parsed[index] = value; + } + return parsed; + } + + private static boolean semanticIpv4(int[] address) { + return address[0] == 127 + || (address[0] == 0 && address[1] == 0 && address[2] == 0 && address[3] == 0); + } + + private static int[] parseIpv6(String host) { + String expandedIpv4 = expandIpv4Tail(host); + if (expandedIpv4 == null || expandedIpv4.contains(":::")) { + return null; + } + + int compression = expandedIpv4.indexOf("::"); + if (compression >= 0 && compression != expandedIpv4.lastIndexOf("::")) { + return null; + } + if (compression < 0) { + String[] groups = expandedIpv4.split(":", -1); + return groups.length == 8 ? parseIpv6Groups(groups) : null; + } + + String[] halves = expandedIpv4.split("::", -1); + if (halves.length != 2) { + return null; + } + String[] left = halves[0].isEmpty() ? new String[0] : halves[0].split(":", -1); + String[] right = halves[1].isEmpty() ? new String[0] : halves[1].split(":", -1); + if (left.length + right.length >= 8) { + return null; + } + + int[] parsed = new int[8]; + if (!parseIpv6Groups(left, parsed, 0) + || !parseIpv6Groups(right, parsed, 8 - right.length)) { + return null; + } + return parsed; + } + + private static String expandIpv4Tail(String host) { + int lastDot = host.lastIndexOf('.'); + if (lastDot < 0) { + return host; + } + int lastColon = host.lastIndexOf(':'); + if (lastColon < 0 || lastDot < lastColon) { + return null; + } + int[] ipv4 = parseIpv4(host.substring(lastColon + 1)); + if (ipv4 == null) { + return null; + } + int high = (ipv4[0] << 8) | ipv4[1]; + int low = (ipv4[2] << 8) | ipv4[3]; + return host.substring(0, lastColon + 1) + + Integer.toHexString(high) + + ':' + + Integer.toHexString(low); + } + + private static int[] parseIpv6Groups(String[] groups) { + int[] parsed = new int[groups.length]; + return parseIpv6Groups(groups, parsed, 0) ? parsed : null; + } + + private static boolean parseIpv6Groups(String[] groups, int[] target, int offset) { + for (int index = 0; index < groups.length; index++) { + String group = groups[index]; + if (group.isEmpty() || group.length() > 4) { + return false; + } + int value = 0; + for (int characterIndex = 0; characterIndex < group.length(); characterIndex++) { + int digit = Character.digit(group.charAt(characterIndex), 16); + if (digit < 0) { + return false; + } + value = (value << 4) | digit; + } + target[offset + index] = value; + } + return true; + } + + private static boolean semanticIpv6(int[] address) { + boolean unspecified = true; + for (int group : address) { + unspecified &= group == 0; + } + if (unspecified || (allZero(address, 7) && address[7] == 1)) { + return true; + } + if (allZero(address, 6) || (allZero(address, 5) && address[5] == 0xffff)) { + return semanticIpv4(new int[] {address[6] >>> 8, address[6] & 0xff, address[7] >>> 8, address[7] & 0xff}); + } + return false; + } + + private static boolean allZero(int[] address, int exclusiveEnd) { + for (int index = 0; index < exclusiveEnd; index++) { + if (address[index] != 0) { + return false; + } + } + return true; + } + + static DiscoveryFailedException failure() { + return new DiscoveryFailedException(); + } + + record SentinelEndpoint(String host, int port) {} + + record DataEndpoint(String host, int port) {} + + record MasterObservation(String host, String port) {} + + @FunctionalInterface + interface SentinelQuery { + + MasterObservation query(SentinelEndpoint sentinel, String masterName) throws Exception; + } + + static final class DiscoveryFailedException extends RuntimeException { + + private DiscoveryFailedException() { + super(FAILURE_MESSAGE); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRefreshWorker.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRefreshWorker.java new file mode 100644 index 0000000..f381e9d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRefreshWorker.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; + +/** Adapter-private finite worker seam for deterministic Sentinel refresh tests. */ +interface RedisSentinelRefreshWorker { + + Cancellable scheduleWithFixedDelay(Runnable task, Duration delay); + + boolean execute(Runnable task); + + void shutdown(Duration timeout); + + @FunctionalInterface + interface Cancellable { + + void cancel(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnector.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnector.java new file mode 100644 index 0000000..642c748 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnector.java @@ -0,0 +1,172 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisURI; +import io.lettuce.core.SslOptions; +import java.time.Clock; +import java.util.Objects; + +/** Adapter-private split between Sentinel discovery and exact approved-route data connection. */ +interface RedisSentinelRuntimeConnector { + + RedisSentinelDiscoveredRoute discover(RedisDeploymentSettings.Sentinel deployment); + + RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, RedisSentinelDiscoveredRoute discoveredRoute); +} + +final class DefaultRedisSentinelRuntimeConnector implements RedisSentinelRuntimeConnector { + + private static final String DATA_ROUTE_FAILURE = "Redis Sentinel data route is not approved"; + private static final String DATA_OPEN_FAILURE = "Redis Sentinel data runtime opening failed"; + + private final RedisClientRuntimeSettings settings; + private final int maximumBulkBytes; + private final RedisLettuceUriFactory uriFactory; + private final RedisSslOptionsFactory sslOptionsFactory; + private final SentinelDiscovery sentinelDiscovery; + private final DataRuntimeOpener dataRuntimeOpener; + + DefaultRedisSentinelRuntimeConnector( + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + RedisCredentialMaterialProvider credentialProvider, + RedisTrustMaterialProvider trustProvider, + Clock clock) { + this( + settings, + maximumBulkBytes, + credentialProvider, + trustProvider, + clock, + (deployment, uris, clientSettings, sentinelOptions) -> + new RedisSentinelDiscoveryClient(deployment, uris, clientSettings, sentinelOptions) + .discover(), + RedisTopologyCommandRuntime::openSentinelData); + } + + DefaultRedisSentinelRuntimeConnector( + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + RedisCredentialMaterialProvider credentialProvider, + RedisTrustMaterialProvider trustProvider, + Clock clock, + SentinelDiscovery sentinelDiscovery, + DataRuntimeOpener dataRuntimeOpener) { + this.settings = Objects.requireNonNull(settings, "settings must be non-null"); + if (maximumBulkBytes < 1) { + throw new IllegalArgumentException("maximumBulkBytes must be positive"); + } + this.maximumBulkBytes = maximumBulkBytes; + this.uriFactory = + new RedisLettuceUriFactory( + Objects.requireNonNull(credentialProvider, "credentialProvider must be non-null"), + Objects.requireNonNull(clock, "clock must be non-null")); + this.sslOptionsFactory = + new RedisSslOptionsFactory( + Objects.requireNonNull(trustProvider, "trustProvider must be non-null"), clock); + this.sentinelDiscovery = + Objects.requireNonNull(sentinelDiscovery, "sentinelDiscovery must be non-null"); + this.dataRuntimeOpener = + Objects.requireNonNull(dataRuntimeOpener, "dataRuntimeOpener must be non-null"); + } + + @Override + public RedisSentinelDiscoveredRoute discover(RedisDeploymentSettings.Sentinel deployment) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + try { + SslOptions sentinelTls = + sslOptionsFactory.create(deployment.sentinelTls(), settings.tlsHandshakeTimeout()); + try (RedisLettuceUris.SentinelDiscovery uris = + uriFactory.createSentinelDiscovery(deployment, settings)) { + ClientOptions sentinelOptions = + new RedisLettuceClientOptionsFactory().clientOptions(settings, sentinelTls); + RedisSentinelMasterDiscovery.DataEndpoint endpoint = + sentinelDiscovery.discover(deployment, uris, settings, sentinelOptions); + return RedisSentinelDiscoveredRoute.fromQuorum(endpoint); + } + } catch (RedisSentinelMasterDiscovery.DiscoveryFailedException exception) { + throw exception; + } catch (RuntimeException exception) { + throw RedisSentinelMasterDiscovery.failure(); + } + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, RedisSentinelDiscoveredRoute discoveredRoute) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(discoveredRoute, "discoveredRoute must be non-null"); + if (!isAllowed(deployment, discoveredRoute.endpoint())) { + throw new IllegalStateException(DATA_ROUTE_FAILURE); + } + try { + SslOptions dataTls = + sslOptionsFactory.create(deployment.dataTls(), settings.tlsHandshakeTimeout()); + return uriFactory.mapOwnedSentinelData( + deployment, + discoveredRoute.endpoint(), + settings, + uris -> { + RedisRoutableCommandRuntime runtime = + dataRuntimeOpener.open( + deployment.deploymentId(), + uris.dataUri(), + discoveredRoute.identity(), + uris, + settings, + maximumBulkBytes, + dataTls); + if (runtime == null) { + throw new IllegalStateException(DATA_OPEN_FAILURE); + } + return runtime; + }); + } catch (RedisTemporaryConnectionException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IllegalStateException(DATA_OPEN_FAILURE); + } + } + + private static boolean isAllowed( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelMasterDiscovery.DataEndpoint endpoint) { + return deployment.dataEndpoints().stream() + .anyMatch( + configured -> + RedisSentinelMasterDiscovery.sameEndpoint( + new RedisSentinelMasterDiscovery.SentinelEndpoint( + configured.host(), configured.port()), + endpoint.host(), + endpoint.port())); + } + + @FunctionalInterface + interface SentinelDiscovery { + + RedisSentinelMasterDiscovery.DataEndpoint discover( + RedisDeploymentSettings.Sentinel deployment, + RedisLettuceUris.SentinelDiscovery uris, + RedisClientRuntimeSettings settings, + ClientOptions sentinelOptions); + } + + @FunctionalInterface + interface DataRuntimeOpener { + + RedisRoutableCommandRuntime open( + String deploymentId, + RedisURI directDataUri, + RedisRouteIdentity routeIdentity, + RedisLettuceUris.SentinelData credentialOwner, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + SslOptions dataTls); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfig.java new file mode 100644 index 0000000..e5e375f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfig.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import java.time.Clock; +import java.util.Arrays; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.SessionRepository; + +/** + * Explicit Redis SESSION-role repository composition. + * + *

Servlet cookie, CSRF and fixation policy remain owned by the inbound web module. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties({RedisSessionSettings.class, RedisProviderSettings.class}) +@ConditionalOnProperty( + name = "ca-skeleton.security.auth-mode", + havingValue = "redis-session", + matchIfMissing = false) +public class RedisSessionConfig { + + private static final int COMMAND_OVERHEAD_BYTES = 8_192; + + @Bean(name = "redisLuaVersionedSessionStore", destroyMethod = "close") + @ConditionalOnProperty( + name = "ca-skeleton.security.auth-mode", + havingValue = "redis-session", + matchIfMissing = false) + RedisLuaVersionedSessionStore redisLuaVersionedSessionStore( + RedisSessionSettings settings, + RedisProviderSettings providerSettings, + RedisCanonicalRoleRegistry roleRegistry, + RedisCredentialMaterialProvider credentialProvider, + ObjectProvider clockProvider, + ObjectProvider observationsProvider) { + validateActivation(settings, providerSettings); + Clock clock = clockProvider.getIfAvailable(Clock::systemUTC); + RedisCapabilityObservationPort observations = + observationsProvider.getIfUnique(NoOpRedisCapabilityObservationPort::instance); + byte[] hmacSecret = + RedisHmacMaterialResolver.resolve( + settings.keyHmacSecretReference(), credentialProvider, clock, "session"); + try { + return new RedisLuaVersionedSessionStore( + roleRegistry.router(RedisRole.SESSION), + settings.namespaceApplication(), + settings.namespaceEnvironment(), + settings.hashKeyVersion(), + settings.keyVersion(), + hmacSecret, + observations, + System::nanoTime); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + + @Bean(name = "redisVersionedSessionRepository") + @ConditionalOnProperty( + name = "ca-skeleton.security.auth-mode", + havingValue = "redis-session", + matchIfMissing = false) + SessionRepository redisVersionedSessionRepository( + RedisSessionSettings settings, + RedisLuaVersionedSessionStore store, + ObjectProvider clockProvider) { + return new RedisVersionedSessionRepository( + store, + new RedisSessionEnvelopeCodec( + settings.maximumEnvelopeBytes(), + settings.maximumAttributes(), + settings.maximumScalarBytes()), + clockProvider.getIfAvailable(Clock::systemUTC), + settings.idleTimeout(), + settings.absoluteLifetime(), + settings.touchInterval(), + settings.tombstoneTimeToLive()); + } + + static void validateActivation( + RedisSessionSettings settings, RedisProviderSettings providerSettings) { + RedisRoleBinding binding = providerSettings.roles().get(RedisRole.SESSION); + if (binding == null || !binding.required()) { + throw new IllegalStateException( + "redis-session auth mode requires a required canonical Redis SESSION role"); + } + RedisProviderSettings.DeploymentProperties deployment = + providerSettings.deployments().get(binding.deploymentId()); + if (deployment == null) { + throw new IllegalStateException("Redis SESSION role references an unknown deployment"); + } + if (deployment.topology() != RedisProviderSettings.Topology.STANDALONE) { + throw new IllegalStateException( + "Redis SESSION currently supports only the verified standalone topology; Cluster" + + " rotation is cross-slot and Sentinel is not qualified"); + } + if (settings.tombstoneTimeToLive().compareTo(providerSettings.runtime().routeDrainTimeout()) + <= 0) { + throw new IllegalStateException( + "Redis session tombstone TTL must exceed the route shutdown/drain budget"); + } + long encodedEnvelopeBytes = ((long) settings.maximumEnvelopeBytes() + 2L) / 3L * 4L; + if (encodedEnvelopeBytes + COMMAND_OVERHEAD_BYTES + > providerSettings.runtime().maximumCommandBytes()) { + throw new IllegalStateException( + "Redis session envelope exceeds the SESSION router command byte bound after Base64"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodec.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodec.java new file mode 100644 index 0000000..3a3192a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodec.java @@ -0,0 +1,296 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * Versioned session envelope with an explicit primitive allowlist and corruption-detecting + * checksum. + * + *

The unkeyed checksum is not an authenticity or attacker-tamper-protection guarantee. + */ +final class RedisSessionEnvelopeCodec { + + private static final int MAGIC = 0x5253534e; + private static final int CURRENT_VERSION = 2; + private static final int PREVIOUS_VERSION = 1; + private static final int DIGEST_BYTES = 32; + + private static final int STRING = 1; + private static final int BOOLEAN = 2; + private static final int INTEGER = 3; + private static final int LONG = 4; + private static final int INSTANT = 5; + private static final int UUID_VALUE = 6; + private static final int BYTES = 7; + + private final int maximumEnvelopeBytes; + private final int maximumAttributes; + private final int maximumScalarBytes; + + RedisSessionEnvelopeCodec( + int maximumEnvelopeBytes, int maximumAttributes, int maximumScalarBytes) { + if (maximumEnvelopeBytes < 64 || maximumEnvelopeBytes > 1_048_576) { + throw new IllegalArgumentException("maximumEnvelopeBytes must be in 64..1048576"); + } + if (maximumAttributes < 1 || maximumAttributes > 256) { + throw new IllegalArgumentException("maximumAttributes must be in 1..256"); + } + if (maximumScalarBytes < 1 || maximumScalarBytes > maximumEnvelopeBytes) { + throw new IllegalArgumentException("maximumScalarBytes must fit the envelope"); + } + this.maximumEnvelopeBytes = maximumEnvelopeBytes; + this.maximumAttributes = maximumAttributes; + this.maximumScalarBytes = maximumScalarBytes; + } + + byte[] encode(RedisSessionSnapshot snapshot) { + return encode(snapshot, CURRENT_VERSION); + } + + byte[] encodePreviousVersionForTest(RedisSessionSnapshot snapshot) { + return encode(snapshot, PREVIOUS_VERSION); + } + + int version(byte[] envelope) { + requireEnvelopeBound(envelope); + if (readInt(envelope, 0) != MAGIC) { + throw corrupt(); + } + return envelope[Integer.BYTES] & 0xff; + } + + RedisSessionSnapshot decode(byte[] envelope) { + requireEnvelopeBound(envelope); + if (envelope.length <= DIGEST_BYTES) { + throw corrupt(); + } + byte[] body = Arrays.copyOf(envelope, envelope.length - DIGEST_BYTES); + byte[] suppliedDigest = Arrays.copyOfRange(envelope, body.length, envelope.length); + if (!MessageDigest.isEqual(sha256(body), suppliedDigest)) { + throw corrupt(); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != MAGIC) { + throw corrupt(); + } + int version = input.readUnsignedByte(); + if (version != CURRENT_VERSION && version != PREVIOUS_VERSION) { + throw corrupt(); + } + Instant createdAt = Instant.ofEpochMilli(input.readLong()); + Instant lastAccessedAt = Instant.ofEpochMilli(input.readLong()); + Instant absoluteExpiresAt = Instant.ofEpochMilli(input.readLong()); + Duration idleTimeout = Duration.ofMillis(input.readLong()); + long revision = input.readLong(); + int count = input.readInt(); + if (count < 0 || count > maximumAttributes) { + throw corrupt(); + } + Map attributes = new LinkedHashMap<>(); + for (int index = 0; index < count; index++) { + String name = readString(input); + if (attributes.put(name, readValue(input)) != null) { + throw corrupt(); + } + } + if (input.available() != 0) { + throw corrupt(); + } + return new RedisSessionSnapshot( + createdAt, lastAccessedAt, absoluteExpiresAt, idleTimeout, revision, attributes); + } catch (IOException | IllegalArgumentException exception) { + throw corrupt(); + } + } + + private byte[] encode(RedisSessionSnapshot snapshot, int version) { + Objects.requireNonNull(snapshot, "snapshot"); + if (snapshot.attributes().size() > maximumAttributes) { + throw new IllegalArgumentException("session attribute count exceeds configured maximum"); + } + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(MAGIC); + output.writeByte(version); + output.writeLong(snapshot.createdAt().toEpochMilli()); + output.writeLong(snapshot.lastAccessedAt().toEpochMilli()); + output.writeLong(snapshot.absoluteExpiresAt().toEpochMilli()); + output.writeLong(snapshot.idleTimeout().toMillis()); + output.writeLong(snapshot.revision()); + output.writeInt(snapshot.attributes().size()); + for (Map.Entry entry : snapshot.attributes().entrySet()) { + writeString(output, entry.getKey()); + writeValue(output, entry.getValue()); + } + } + byte[] body = bytes.toByteArray(); + if ((long) body.length + DIGEST_BYTES > maximumEnvelopeBytes) { + throw new IllegalArgumentException("session envelope exceeds configured maximum"); + } + byte[] result = Arrays.copyOf(body, body.length + DIGEST_BYTES); + System.arraycopy(sha256(body), 0, result, body.length, DIGEST_BYTES); + return result; + } catch (IOException exception) { + throw new IllegalStateException("in-memory session encoding failed", exception); + } + } + + private void writeValue(DataOutputStream output, Object value) throws IOException { + Objects.requireNonNull(value, "session attributes must be non-null"); + switch (value) { + case String text -> { + output.writeByte(STRING); + writeString(output, text); + } + case Boolean flag -> { + output.writeByte(BOOLEAN); + output.writeBoolean(flag); + } + case Integer number -> { + output.writeByte(INTEGER); + output.writeInt(number); + } + case Long number -> { + output.writeByte(LONG); + output.writeLong(number); + } + case Instant instant -> { + output.writeByte(INSTANT); + output.writeLong(instant.toEpochMilli()); + } + case UUID uuid -> { + output.writeByte(UUID_VALUE); + output.writeLong(uuid.getMostSignificantBits()); + output.writeLong(uuid.getLeastSignificantBits()); + } + case byte[] binary -> { + output.writeByte(BYTES); + writeBytes(output, binary); + } + default -> + throw new IllegalArgumentException( + "session attribute type is not in the explicit allowlist: " + + value.getClass().getName()); + } + } + + private Object readValue(DataInputStream input) throws IOException { + return switch (input.readUnsignedByte()) { + case STRING -> readString(input); + case BOOLEAN -> input.readBoolean(); + case INTEGER -> input.readInt(); + case LONG -> input.readLong(); + case INSTANT -> Instant.ofEpochMilli(input.readLong()); + case UUID_VALUE -> new UUID(input.readLong(), input.readLong()); + case BYTES -> readBytes(input); + default -> throw corrupt(); + }; + } + + private void writeString(DataOutputStream output, String value) throws IOException { + Objects.requireNonNull(value, "session string must be non-null"); + writeBytes(output, value.getBytes(StandardCharsets.UTF_8)); + } + + private String readString(DataInputStream input) throws IOException { + return new String(readBytes(input), StandardCharsets.UTF_8); + } + + private void writeBytes(DataOutputStream output, byte[] value) throws IOException { + if (value.length > maximumScalarBytes) { + throw new IllegalArgumentException("session scalar exceeds configured maximum"); + } + output.writeInt(value.length); + output.write(value); + } + + private byte[] readBytes(DataInputStream input) throws IOException { + int length = input.readInt(); + if (length < 0 || length > maximumScalarBytes || length > input.available()) { + throw new EOFException("invalid bounded scalar length"); + } + return input.readNBytes(length); + } + + private void requireEnvelopeBound(byte[] value) { + if (value == null || value.length < 1 || value.length > maximumEnvelopeBytes) { + throw corrupt(); + } + } + + private static int readInt(byte[] value, int offset) { + if (value.length < offset + Integer.BYTES) { + throw corrupt(); + } + return ((value[offset] & 0xff) << 24) + | ((value[offset + 1] & 0xff) << 16) + | ((value[offset + 2] & 0xff) << 8) + | (value[offset + 3] & 0xff); + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable for Redis session envelope", exception); + } + } + + private static RedisSessionCorruptPayloadException corrupt() { + return new RedisSessionCorruptPayloadException(); + } +} + +record RedisSessionSnapshot( + Instant createdAt, + Instant lastAccessedAt, + Instant absoluteExpiresAt, + Duration idleTimeout, + long revision, + Map attributes) { + + RedisSessionSnapshot( + Instant createdAt, + Instant lastAccessedAt, + Instant absoluteExpiresAt, + Duration idleTimeout, + long revision, + Map attributes) { + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.lastAccessedAt = Objects.requireNonNull(lastAccessedAt, "lastAccessedAt"); + this.absoluteExpiresAt = Objects.requireNonNull(absoluteExpiresAt, "absoluteExpiresAt"); + this.idleTimeout = Objects.requireNonNull(idleTimeout, "idleTimeout"); + if (this.lastAccessedAt.isBefore(this.createdAt) + || !this.absoluteExpiresAt.isAfter(this.createdAt) + || this.idleTimeout.isZero() + || this.idleTimeout.isNegative() + || revision < 1) { + throw new IllegalArgumentException("invalid Redis session temporal or revision metadata"); + } + this.revision = revision; + this.attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes")); + } +} + +final class RedisSessionCorruptPayloadException extends RuntimeException { + + RedisSessionCorruptPayloadException() { + super("Redis session payload is corrupt or incompatible"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettings.java new file mode 100644 index 0000000..4431e05 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettings.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Bounded custom Redis session repository settings; all secret material remains reference-only. */ +@ConfigurationProperties(prefix = "ca-skeleton.capabilities.security.redis-session") +public record RedisSessionSettings( + String keyHmacSecretReference, + String namespaceApplication, + String namespaceEnvironment, + int hashKeyVersion, + int keyVersion, + Duration idleTimeout, + Duration absoluteLifetime, + Duration touchInterval, + Duration tombstoneTimeToLive, + int maximumEnvelopeBytes, + int maximumAttributes, + int maximumScalarBytes) { + + @ConstructorBinding + public RedisSessionSettings( + String keyHmacSecretReference, + String namespaceApplication, + String namespaceEnvironment, + int hashKeyVersion, + int keyVersion, + Duration idleTimeout, + Duration absoluteLifetime, + Duration touchInterval, + Duration tombstoneTimeToLive, + int maximumEnvelopeBytes, + int maximumAttributes, + int maximumScalarBytes) { + this.keyHmacSecretReference = secretReference(keyHmacSecretReference, "keyHmacSecretReference"); + this.namespaceApplication = slug(namespaceApplication, "namespaceApplication"); + this.namespaceEnvironment = slug(namespaceEnvironment, "namespaceEnvironment"); + this.hashKeyVersion = version(hashKeyVersion, "hashKeyVersion"); + this.keyVersion = version(keyVersion, "keyVersion"); + this.idleTimeout = duration(idleTimeout, Duration.ofMinutes(30), "idleTimeout"); + this.absoluteLifetime = duration(absoluteLifetime, Duration.ofHours(8), "absoluteLifetime"); + this.touchInterval = duration(touchInterval, Duration.ofMinutes(1), "touchInterval"); + this.tombstoneTimeToLive = + duration(tombstoneTimeToLive, Duration.ofMinutes(5), "tombstoneTimeToLive"); + this.maximumEnvelopeBytes = + bounded(maximumEnvelopeBytes, 32_768, 64, 1_048_576, "maximumEnvelopeBytes"); + this.maximumAttributes = bounded(maximumAttributes, 64, 1, 256, "maximumAttributes"); + this.maximumScalarBytes = + bounded(maximumScalarBytes, 8_192, 1, this.maximumEnvelopeBytes, "maximumScalarBytes"); + if (this.touchInterval.compareTo(this.idleTimeout) >= 0) { + throw new IllegalArgumentException("touchInterval must be shorter than idleTimeout"); + } + if (this.idleTimeout.compareTo(this.absoluteLifetime) > 0) { + throw new IllegalArgumentException("idleTimeout must not exceed absoluteLifetime"); + } + } + + private static String secretReference(String value, String field) { + if (value == null + || value.length() > 512 + || !value.matches("secret://[a-z][a-z0-9-]{1,62}/[A-Za-z0-9_.-]{1,255}")) { + throw new IllegalArgumentException(field + " must be a bounded secret reference"); + } + return value; + } + + private static String slug(String value, String field) { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException(field + " must be a bounded lowercase slug"); + } + return value; + } + + private static int version(int value, String field) { + int resolved = value == 0 ? 1 : value; + if (resolved < 1 || resolved > 9_999) { + throw new IllegalArgumentException(field + " must be in 1..9999"); + } + return resolved; + } + + private static int bounded(int value, int fallback, int minimum, int maximum, String field) { + int resolved = value == 0 ? fallback : value; + if (resolved < minimum || resolved > maximum) { + throw new IllegalArgumentException(field + " is outside its bounded range"); + } + return resolved; + } + + private static Duration duration(Duration value, Duration fallback, String field) { + Duration resolved = value == null ? fallback : value; + if (resolved.isZero() || resolved.isNegative() || resolved.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException(field + " must be positive and at most 30 days"); + } + return resolved; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSetPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSetPrimitives.java new file mode 100644 index 0000000..27a2769 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSetPrimitives.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Bounded set helpers. Adds use atomic capacity admission; there is no unbounded members call. */ +final class RedisSetPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + private final RedisPrimitiveDescriptor admission; + + RedisSetPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + this.admission = catalog.descriptor(RedisPrimitiveId.SET_ADMIT); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.SET_ADMIT).key(slot, identity); + } + + RedisPrimitiveValue member(String value) { + return RedisPrimitiveValue.utf8(value, admission.maximumMemberBytes()); + } + + RedisPrimitiveMutationResult admit( + RedisPrimitiveKey key, RedisPrimitiveValue member, Duration initialTimeToLive) { + return executor.mutate( + RedisPrimitiveId.SET_ADMIT, + List.of(key), + new RedisPrimitiveInvocation.CapacityArguments( + member, + RedisPrimitiveLimit.of(admission.maximumElements(), admission), + initialTimeToLive)); + } + + RedisPrimitiveReply contains(RedisPrimitiveKey key, RedisPrimitiveValue member) { + return executor.execute( + RedisPrimitiveId.SET_CONTAINS, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(List.of(member))); + } + + RedisPrimitiveMutationResult remove(RedisPrimitiveKey key, List members) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.SET_REMOVE); + RedisPrimitiveLimit.of(members.size(), descriptor); + return executor.mutate( + RedisPrimitiveId.SET_REMOVE, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(members)); + } + + RedisPrimitiveReply cardinality(RedisPrimitiveKey key) { + return executor.execute( + RedisPrimitiveId.SET_CARDINALITY, + List.of(key), + RedisPrimitiveInvocation.NoArguments.INSTANCE); + } + + RedisPrimitiveScanOutcome scan( + RedisPrimitiveKey key, RedisPrimitiveCursor cursor, long routeEpoch) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.SET_SCAN_PAGE); + cursor.validateFor(catalog, descriptor, key, routeEpoch); + return RedisPrimitiveScanOutcome.from( + executor.execute( + RedisPrimitiveId.SET_SCAN_PAGE, + List.of(key), + new RedisPrimitiveInvocation.ScanPageArguments( + cursor, descriptor.maximumElements(), descriptor.maximumResultBytes())), + RedisPrimitiveValue.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetPrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetPrimitives.java new file mode 100644 index 0000000..6acbfee --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetPrimitives.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Bounded sorted-set helpers with atomic growth admission and removal-cost guarded trim. */ +final class RedisSortedSetPrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + private final RedisPrimitiveDescriptor admission; + + RedisSortedSetPrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + this.admission = catalog.descriptor(RedisPrimitiveId.ZSET_ADD); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.ZSET_ADD).key(slot, identity); + } + + RedisPrimitiveValue member(String member) { + return RedisPrimitiveValue.utf8(member, admission.maximumMemberBytes()); + } + + RedisPrimitiveMutationResult admitOrUpdate( + RedisPrimitiveKey key, + RedisPrimitiveValue member, + RedisSortedSetScore score, + Duration initialTimeToLive) { + return executor.mutate( + RedisPrimitiveId.ZSET_ADD, + List.of(key), + new RedisPrimitiveInvocation.SortedSetAdmissionArguments( + member, + score, + RedisPrimitiveLimit.of(admission.maximumElements(), admission), + initialTimeToLive)); + } + + RedisPrimitiveMutationResult remove(RedisPrimitiveKey key, List members) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.ZSET_REMOVE); + RedisPrimitiveLimit.of(members.size(), descriptor); + return executor.mutate( + RedisPrimitiveId.ZSET_REMOVE, + List.of(key), + new RedisPrimitiveInvocation.BinaryArguments(members)); + } + + RedisPrimitiveReply count( + RedisPrimitiveKey key, RedisSortedSetScore minimum, RedisSortedSetScore maximum) { + return executor.execute( + RedisPrimitiveId.ZSET_COUNT, + List.of(key), + scoreRange(RedisPrimitiveId.ZSET_COUNT, minimum, maximum, 0, 1)); + } + + RedisPrimitiveReply rankPage(RedisPrimitiveKey key, long offset, int count) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.ZSET_RANK_PAGE); + RedisPrimitiveLimit limit = RedisPrimitiveLimit.of(count, descriptor); + long last = Math.addExact(offset, count - 1L); + return executor.execute( + RedisPrimitiveId.ZSET_RANK_PAGE, + List.of(key), + new RedisPrimitiveInvocation.RangeArguments(offset, last, limit)); + } + + RedisPrimitiveReply scorePage( + RedisPrimitiveKey key, + RedisSortedSetScore minimum, + RedisSortedSetScore maximum, + long offset, + int count) { + return executor.execute( + RedisPrimitiveId.ZSET_SCORE_PAGE, + List.of(key), + scoreRange(RedisPrimitiveId.ZSET_SCORE_PAGE, minimum, maximum, offset, count)); + } + + RedisPrimitiveMutationResult trimBelowOrEqual(RedisPrimitiveKey key, RedisSortedSetScore cutoff) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.ZSET_TRIM_BOUNDED); + return executor.mutate( + RedisPrimitiveId.ZSET_TRIM_BOUNDED, + List.of(key), + new RedisPrimitiveInvocation.AtomicArguments( + List.of( + RedisPrimitiveValue.utf8(cutoff.canonical(), 128), + RedisPrimitiveValue.utf8(Integer.toString(descriptor.maximumElements()), 128)))); + } + + private RedisPrimitiveInvocation.ScoreRangeArguments scoreRange( + RedisPrimitiveId id, + RedisSortedSetScore minimum, + RedisSortedSetScore maximum, + long offset, + int count) { + if (minimum.compareTo(maximum) > 0) { + throw new IllegalArgumentException("sorted-set score range is inverted"); + } + RedisPrimitiveDescriptor descriptor = catalog.descriptor(id); + return new RedisPrimitiveInvocation.ScoreRangeArguments( + minimum, maximum, offset, RedisPrimitiveLimit.of(count, descriptor)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetScore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetScore.java new file mode 100644 index 0000000..c3e2aae --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSortedSetScore.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.math.BigDecimal; +import java.util.Objects; + +/** Finite, canonical and bounded sorted-set score. */ +record RedisSortedSetScore(String canonical) implements Comparable { + + private static final BigDecimal MAXIMUM_ABSOLUTE = new BigDecimal("1000000000000000"); + + RedisSortedSetScore { + Objects.requireNonNull(canonical, "sorted-set score must be non-null"); + if (!canonical.matches("-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,9})?")) { + throw new IllegalArgumentException("sorted-set score must be canonical"); + } + BigDecimal parsed = new BigDecimal(canonical); + if (parsed.abs().compareTo(MAXIMUM_ABSOLUTE) > 0) { + throw new IllegalArgumentException("sorted-set score exceeds descriptor bounds"); + } + canonical = parsed.signum() == 0 ? "0" : parsed.stripTrailingZeros().toPlainString(); + } + + static RedisSortedSetScore of(String canonical) { + return new RedisSortedSetScore(canonical); + } + + @Override + public int compareTo(RedisSortedSetScore other) { + return new BigDecimal(canonical).compareTo(new BigDecimal(other.canonical)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java index a6b8ec7..3064d19 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegion.java @@ -5,98 +5,306 @@ import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Negat import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheEnvelopeCodec.Positive; import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyBuilder; import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyDigest; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; import dev.caskeleton.application.cache.AuthoritativeAbsence; import dev.caskeleton.application.cache.CacheInvalidationOutcome; import dev.caskeleton.application.cache.CacheLookup; import dev.caskeleton.application.cache.CacheRecordIntent; import dev.caskeleton.application.cache.CacheRecordMetadata; import dev.caskeleton.application.cache.CacheRecordOutcome; -import dev.caskeleton.application.cache.CacheRegionPort; +import dev.caskeleton.application.cache.CacheRefreshCoordinationPort; +import dev.caskeleton.application.cache.CacheWriteCondition; import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; /** Semantic string-cache reference adapter using versioned envelopes and finite TTLs. */ -final class RedisStringCacheRegion implements CacheRegionPort { +final class RedisStringCacheRegion implements RedisCacheL2Region, AutoCloseable { + + private static final RedisProgramCatalog FOUNDATION_CATALOG = RedisProgramCatalog.foundation(); + private static final Duration KEY_REVISION_TTL_GRACE = Duration.ofMinutes(5); private final RedisCacheRegionPolicy policy; private final RedisBinaryCommands commands; + private final RedisAtomicPrimitives atomicPrimitives; + private final RedisCacheConsistencyStore consistencyStore; + private final RedisCacheRefreshCoordinator refreshCoordinator; + private final String regionGenerationKey; + private final Clock clock; + private final RedisCapabilityObserver observer; + private final AtomicBoolean closed = new AtomicBoolean(); RedisStringCacheRegion(RedisCacheRegionPolicy policy, RedisBinaryCommands commands) { + this( + policy, + commands, + Clock.systemUTC(), + new RedisCacheConsistencyStore(commands, keyRevisionTtl(policy))); + } + + RedisStringCacheRegion(RedisCacheRegionPolicy policy, RedisBinaryCommands commands, Clock clock) { + this(policy, commands, clock, new RedisCacheConsistencyStore(commands, keyRevisionTtl(policy))); + } + + RedisStringCacheRegion( + RedisCacheRegionPolicy policy, + RedisBinaryCommands commands, + Clock clock, + RedisCacheConsistencyStore consistencyStore) { + this( + policy, + commands, + clock, + consistencyStore, + NoOpRedisCapabilityObservationPort.instance(), + System::nanoTime); + } + + RedisStringCacheRegion( + RedisCacheRegionPolicy policy, + RedisBinaryCommands commands, + Clock clock, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { + this( + policy, + commands, + clock, + new RedisCacheConsistencyStore(commands, keyRevisionTtl(policy)), + observations, + ticker); + } + + RedisStringCacheRegion( + RedisCacheRegionPolicy policy, + RedisBinaryCommands commands, + Clock clock, + RedisCacheConsistencyStore consistencyStore, + RedisCapabilityObservationPort observations, + LongSupplier ticker) { this.policy = Objects.requireNonNull(policy, "policy must be non-null"); this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + this.atomicPrimitives = + new RedisAtomicPrimitives( + FOUNDATION_CATALOG, new RedisLuaProgramExecutor(FOUNDATION_CATALOG, commands)); + this.consistencyStore = + Objects.requireNonNull(consistencyStore, "consistencyStore must be non-null"); + byte[] refreshSecret = policy.hmacSecret(); + try { + this.refreshCoordinator = + new RedisCacheRefreshCoordinator( + policy.namespace(), refreshSecret, commands, observations, ticker); + } finally { + Arrays.fill(refreshSecret, (byte) 0); + } + this.regionGenerationKey = regionGenerationKey(); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.observer = new RedisCapabilityObserver(observations, ticker); + } + + CacheRefreshCoordinationPort refreshCoordinator() { + ensureOpen(); + return refreshCoordinator; + } + + @Override + public String localEntryIdentity(String key) { + ensureOpen(); + KeyMaterial keyMaterial = keyMaterial(key); + return RedisKeyBuilder.build(policy.namespace(), keyMaterial.digest()); + } + + @Override + public String currentRegionGeneration() { + ensureOpen(); + return consistencyStore.currentRegionGeneration(regionGenerationKey); + } + + String invalidationChannel() { + ensureOpen(); + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + policy.namespace().hashKeyVersion(), + policy.hmacSecret(), + List.of("cache-invalidation-channel".getBytes(StandardCharsets.US_ASCII))); + return RedisKeyBuilder.build(namespace("invalidation-channel"), digest); } @Override public CacheLookup lookup(String key) { - byte[] physicalKey = physicalKey(key); + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + () -> lookupOpen(key), + RedisStringCacheRegion::classifyLookup); + } + + private CacheLookup lookupOpen(String key) { + ensureOpen(); + KeyMaterial keyMaterial = keyMaterial(key); + RedisCacheConsistencyStore.Snapshot snapshot = null; byte[] envelope; try { - envelope = commands.get(physicalKey); + snapshot = consistencyStore.capture(regionGenerationKey, keyMaterial.keyRevisionKey()); + byte[] physicalKey = physicalKey(keyMaterial, snapshot); + envelope = commands.get(RedisPhysicalKey.owned(new CacheKeyMaterial(physicalKey))); } catch (RedisValueTooLargeException exception) { return new CacheLookup.IncompatibleSchema<>( - CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, - CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD); + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.FAIL_FAST, + dev.caskeleton.application.cache.CacheObservationToken.unavailable(), + writeCondition(snapshot)); } catch (RedisCommandFailureException exception) { return new CacheLookup.Unavailable<>( exception.kind() == RedisCommandFailureException.Kind.OVERLOADED ? CacheLookup.UnavailabilityReason.OVERLOADED : CacheLookup.UnavailabilityReason.UNAVAILABLE, - certainty(exception)); + certainty(exception), + writeCondition(snapshot)); } + CacheWriteCondition writeCondition = snapshot.toWriteCondition(); if (envelope == null) { - return new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT); + return new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT, writeCondition); } var decoded = RedisCacheEnvelopeCodec.decode(envelope, policy.maximumValueBytes()); if (decoded instanceof Positive positive) { + Instant now = clock.instant(); + if (!now.isBefore(positive.hardExpiresAt())) { + return new CacheLookup.Miss<>(CacheLookup.MissReason.EXPIRED, writeCondition); + } return new CacheLookup.Hit<>( - positive.value(), CacheLookup.Freshness.FRESH, positive.sourceRevision()); + positive.value(), + now.isBefore(positive.softExpiresAt()) + ? CacheLookup.Freshness.FRESH + : CacheLookup.Freshness.STALE, + positive.sourceRevision(), + positive.softExpiresAt(), + positive.hardExpiresAt(), + positive.observationToken(), + writeCondition); } if (decoded instanceof Negative negative) { - return new CacheLookup.NegativeHit<>(negative.reason()); + if (!clock.instant().isBefore(negative.hardExpiresAt())) { + return new CacheLookup.Miss<>(CacheLookup.MissReason.EXPIRED, writeCondition); + } + return new CacheLookup.NegativeHit<>(negative.reason(), negative.hardExpiresAt()); } Incompatible incompatible = (Incompatible) decoded; return new CacheLookup.IncompatibleSchema<>( incompatible.category(), incompatible.category() == CacheLookup.SchemaCategory.FUTURE_VERSION + || incompatible.category() == CacheLookup.SchemaCategory.CORRUPT_ENVELOPE ? CacheLookup.SchemaPolicy.FAIL_FAST - : CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD); + : CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD, + incompatible.observationToken(), + writeCondition); } @Override public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.RECORD, + () -> recordOpen(key, value, metadata), + RedisStringCacheRegion::classifyRecord); + } + + private CacheRecordOutcome recordOpen(String key, String value, CacheRecordMetadata metadata) { + ensureOpen(); Objects.requireNonNull(metadata, "metadata must be non-null"); if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) { return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; } - byte[] physicalKey = physicalKey(key); - byte[] envelope = - RedisCacheEnvelopeCodec.positive( - value, metadata.sourceRevision(), policy.maximumValueBytes()); - return set(physicalKey, envelope, policy.positiveTtl()); + KeyMaterial keyMaterial = keyMaterial(key); + try { + RedisCacheConsistencyStore.Snapshot snapshot = snapshotForRecord(keyMaterial, metadata); + if (snapshot == null) { + return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; + } + if (!conditionStillCurrent(keyMaterial, metadata, snapshot)) { + return CacheRecordOutcome.NOT_RECORDED_CONDITION; + } + byte[] physicalKey = physicalKey(keyMaterial, snapshot); + Instant now = millisecondInstant(clock.instant()); + RedisCacheRegionPolicy.PositiveExpiry expiry = + policy.positiveExpiry(expiryJitterKey(keyMaterial)); + Instant softExpiresAt = plus(now, expiry.softTtl()); + Instant hardExpiresAt = plus(now, expiry.hardTtl()); + byte[] envelope = + RedisCacheEnvelopeCodec.positive( + value, + metadata.sourceRevision(), + softExpiresAt, + hardExpiresAt, + policy.maximumValueBytes()); + return record(physicalKey, envelope, Duration.between(now, hardExpiresAt), metadata); + } catch (RedisCommandFailureException exception) { + return mutationFailure(exception); + } } @Override public CacheRecordOutcome recordAbsent( String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.RECORD, + () -> recordAbsentOpen(key, reason, metadata), + RedisStringCacheRegion::classifyRecord); + } + + private CacheRecordOutcome recordAbsentOpen( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + ensureOpen(); Objects.requireNonNull(metadata, "metadata must be non-null"); if (metadata.intent() == CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER) { return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; } - byte[] physicalKey = physicalKey(key); - byte[] envelope = - RedisCacheEnvelopeCodec.negative( - reason, metadata.sourceRevision(), policy.maximumValueBytes()); - return set(physicalKey, envelope, policy.negativeTtl()); + KeyMaterial keyMaterial = keyMaterial(key); + try { + RedisCacheConsistencyStore.Snapshot snapshot = snapshotForRecord(keyMaterial, metadata); + if (snapshot == null) { + return CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; + } + if (!conditionStillCurrent(keyMaterial, metadata, snapshot)) { + return CacheRecordOutcome.NOT_RECORDED_CONDITION; + } + byte[] physicalKey = physicalKey(keyMaterial, snapshot); + Instant now = millisecondInstant(clock.instant()); + Instant hardExpiresAt = plus(now, policy.negativeTimeToLive(expiryJitterKey(keyMaterial))); + byte[] envelope = + RedisCacheEnvelopeCodec.negative(reason, hardExpiresAt, policy.maximumValueBytes()); + return record(physicalKey, envelope, Duration.between(now, hardExpiresAt), metadata); + } catch (RedisCommandFailureException exception) { + return mutationFailure(exception); + } } @Override public CacheInvalidationOutcome invalidate(String key) { - byte[] physicalKey = physicalKey(key); + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.INVALIDATE, + () -> invalidateOpen(key), + RedisStringCacheRegion::classifyInvalidation); + } + + private CacheInvalidationOutcome invalidateOpen(String key) { + ensureOpen(); try { - return commands.delete(physicalKey) > 0 - ? CacheInvalidationOutcome.INVALIDATED - : CacheInvalidationOutcome.ALREADY_ABSENT; + consistencyStore.bumpKeyRevision(keyMaterial(key).keyRevisionKey()); + return CacheInvalidationOutcome.INVALIDATED; } catch (RedisCommandFailureException exception) { return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED ? CacheInvalidationOutcome.DEGRADED_UNAVAILABLE @@ -104,10 +312,34 @@ final class RedisStringCacheRegion implements CacheRegionPort { } } - private CacheRecordOutcome set( - byte[] physicalKey, byte[] envelope, java.time.Duration timeToLive) { + @Override + public CacheInvalidationOutcome invalidateRegion() { + return observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.INVALIDATE, + this::invalidateRegionOpen, + RedisStringCacheRegion::classifyInvalidation); + } + + private CacheInvalidationOutcome invalidateRegionOpen() { + ensureOpen(); try { - commands.set(physicalKey, envelope, timeToLive); + consistencyStore.bumpRegionGeneration(regionGenerationKey); + return CacheInvalidationOutcome.INVALIDATED; + } catch (RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? CacheInvalidationOutcome.DEGRADED_UNAVAILABLE + : CacheInvalidationOutcome.INDETERMINATE; + } + } + + private CacheRecordOutcome set(byte[] physicalKey, byte[] envelope, Duration timeToLive) { + try { + commands.set( + RedisPhysicalKey.owned(new CacheKeyMaterial(physicalKey)), + RedisBinaryValue.encoded(envelope), + timeToLive); return CacheRecordOutcome.RECORDED; } catch (RedisCommandFailureException exception) { return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED @@ -116,7 +348,77 @@ final class RedisStringCacheRegion implements CacheRegionPort { } } - private byte[] physicalKey(String key) { + private CacheRecordOutcome record( + byte[] physicalKey, byte[] envelope, Duration timeToLive, CacheRecordMetadata metadata) { + CacheRecordIntent intent = metadata.intent(); + return switch (intent) { + case UPSERT -> set(physicalKey, envelope, timeToLive); + case ONLY_IF_ABSENT -> setIfAbsent(physicalKey, envelope, timeToLive); + case ONLY_IF_OBSERVED -> + replaceIfObserved(physicalKey, envelope, timeToLive, metadata.observedToken()); + case ONLY_IF_SOURCE_REVISION_NEWER -> CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY; + default -> throw new IllegalArgumentException("unsupported cache record intent"); + }; + } + + private CacheRecordOutcome replaceIfObserved( + byte[] physicalKey, + byte[] envelope, + Duration timeToLive, + dev.caskeleton.application.cache.CacheObservationToken observationToken) { + try { + RedisAtomicPrimitives.ReplaceIfObservedResult result = + atomicPrimitives.replaceIfObservedWithTtl( + new String(physicalKey, StandardCharsets.US_ASCII), + observationToken.value(), + envelope, + timeToLive, + "cache-region-v2"); + return switch (result) { + case REPLACED -> CacheRecordOutcome.RECORDED; + case ABSENT, NOT_MATCHED -> CacheRecordOutcome.NOT_RECORDED_CONDITION; + case WRONG_TYPE, INVALID -> + throw new RedisProgramCompatibilityException( + RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, result.name()); + default -> + throw new RedisProgramCompatibilityException( + RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL, result.name()); + }; + } catch (RedisCommandFailureException exception) { + return mutationFailure(exception); + } + } + + private static CacheRecordOutcome mutationFailure(RedisCommandFailureException exception) { + return exception.certainty() == RedisCommandFailureException.Certainty.NOT_APPLIED + ? CacheRecordOutcome.DEGRADED_UNAVAILABLE + : CacheRecordOutcome.INDETERMINATE; + } + + private CacheRecordOutcome setIfAbsent(byte[] physicalKey, byte[] envelope, Duration timeToLive) { + try { + RedisAtomicPrimitives.SetIfAbsentResult result = + atomicPrimitives.setIfAbsentWithTtl( + new String(physicalKey, StandardCharsets.US_ASCII), + envelope, + timeToLive, + "cache-region-v2"); + return switch (result) { + case SET -> CacheRecordOutcome.RECORDED; + case EXISTS -> CacheRecordOutcome.NOT_RECORDED_CONDITION; + case WRONG_TYPE, INVALID -> + throw new RedisProgramCompatibilityException( + RedisProgramId.SET_IF_ABSENT_WITH_TTL, result.name()); + default -> + throw new RedisProgramCompatibilityException( + RedisProgramId.SET_IF_ABSENT_WITH_TTL, result.name()); + }; + } catch (RedisCommandFailureException exception) { + return mutationFailure(exception); + } + } + + private KeyMaterial keyMaterial(String key) { if (key == null || key.isBlank()) { throw new IllegalArgumentException("semantic cache key must be non-blank"); } @@ -125,7 +427,76 @@ final class RedisStringCacheRegion implements CacheRegionPort { policy.namespace().hashKeyVersion(), policy.hmacSecret(), List.of(key.getBytes(StandardCharsets.UTF_8))); - return RedisKeyBuilder.build(policy.namespace(), digest).getBytes(StandardCharsets.UTF_8); + return new KeyMaterial(digest, RedisKeyBuilder.build(namespace("key-revision"), digest)); + } + + private byte[] physicalKey( + KeyMaterial keyMaterial, RedisCacheConsistencyStore.Snapshot snapshot) { + return RedisKeyBuilder.buildVersioned( + policy.namespace(), keyMaterial.digest(), snapshot.generation(), snapshot.keyRevision()) + .getBytes(StandardCharsets.UTF_8); + } + + private byte[] expiryJitterKey(KeyMaterial keyMaterial) { + return RedisKeyBuilder.build(policy.namespace(), keyMaterial.digest()) + .getBytes(StandardCharsets.UTF_8); + } + + private String regionGenerationKey() { + RedisKeyDigest digest = + RedisKeyDigest.sensitive( + policy.namespace().hashKeyVersion(), + policy.hmacSecret(), + List.of("region-generation".getBytes(StandardCharsets.US_ASCII))); + return RedisKeyBuilder.build(namespace("region-generation"), digest); + } + + private RedisKeyNamespace namespace(String kind) { + RedisKeyNamespace namespace = policy.namespace(); + return new RedisKeyNamespace( + namespace.application(), + namespace.environment(), + namespace.capability(), + namespace.region(), + namespace.hashKeyVersion(), + namespace.keyVersion(), + kind, + namespace.maximumKeyBytes()); + } + + private RedisCacheConsistencyStore.Snapshot snapshotForRecord( + KeyMaterial keyMaterial, CacheRecordMetadata metadata) { + if (metadata.intent() == CacheRecordIntent.UPSERT) { + return consistencyStore.capture(regionGenerationKey, keyMaterial.keyRevisionKey()); + } + return consistencyStore.decode(metadata.writeCondition()); + } + + private boolean conditionStillCurrent( + KeyMaterial keyMaterial, + CacheRecordMetadata metadata, + RedisCacheConsistencyStore.Snapshot snapshot) { + if (metadata.intent() == CacheRecordIntent.UPSERT) { + return true; + } + return snapshot.equals( + consistencyStore.capture(regionGenerationKey, keyMaterial.keyRevisionKey())); + } + + private static CacheWriteCondition writeCondition(RedisCacheConsistencyStore.Snapshot snapshot) { + return snapshot == null ? CacheWriteCondition.unavailable() : snapshot.toWriteCondition(); + } + + private static Instant millisecondInstant(Instant value) { + return Instant.ofEpochMilli(value.toEpochMilli()); + } + + private static Instant plus(Instant value, Duration duration) { + try { + return value.plus(duration); + } catch (ArithmeticException | DateTimeException exception) { + throw new IllegalStateException("cache expiry exceeds supported instant range", exception); + } } private static CacheLookup.OperationCertainty certainty(RedisCommandFailureException exception) { @@ -133,4 +504,112 @@ final class RedisStringCacheRegion implements CacheRegionPort { ? CacheLookup.OperationCertainty.NOT_APPLIED : CacheLookup.OperationCertainty.INDETERMINATE; } + + private static Duration keyRevisionTtl(RedisCacheRegionPolicy policy) { + Objects.requireNonNull(policy, "policy must be non-null"); + return policy.maximumEntryTimeToLive().plus(KEY_REVISION_TTL_GRACE); + } + + static RedisCapabilityObserver.Classification classifyLookup(CacheLookup outcome) { + if (outcome instanceof CacheLookup.Hit hit) { + return definite( + hit.freshness() == CacheLookup.Freshness.STALE + ? RedisCapabilityObservationEvent.Outcome.STALE + : RedisCapabilityObservationEvent.Outcome.HIT); + } + if (outcome instanceof CacheLookup.NegativeHit) { + return definite(RedisCapabilityObservationEvent.Outcome.HIT); + } + if (outcome instanceof CacheLookup.Miss) { + return definite(RedisCapabilityObservationEvent.Outcome.MISS); + } + if (outcome instanceof CacheLookup.IncompatibleSchema) { + return definite(RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE); + } + CacheLookup.Unavailable unavailable = (CacheLookup.Unavailable) outcome; + return new RedisCapabilityObserver.Classification( + unavailable.reason() == CacheLookup.UnavailabilityReason.OVERLOADED + ? RedisCapabilityObservationEvent.Outcome.OVERLOADED + : RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + unavailable.certainty() == CacheLookup.OperationCertainty.INDETERMINATE + ? RedisCapabilityObservationEvent.Certainty.INDETERMINATE + : RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + static RedisCapabilityObserver.Classification classifyRecord(CacheRecordOutcome outcome) { + return switch (outcome) { + case RECORDED -> definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case NOT_RECORDED_CONDITION -> definite(RedisCapabilityObservationEvent.Outcome.CONFLICT); + case NOT_RECORDED_PROVIDER_POLICY -> + definite(RedisCapabilityObservationEvent.Outcome.SKIPPED); + case DEGRADED_UNAVAILABLE -> notApplied(RedisCapabilityObservationEvent.Outcome.UNAVAILABLE); + case INDETERMINATE -> indeterminate(RedisCapabilityObservationEvent.Outcome.INDETERMINATE); + }; + } + + private static RedisCapabilityObserver.Classification classifyInvalidation( + CacheInvalidationOutcome outcome) { + return switch (outcome) { + case INVALIDATED, ALREADY_ABSENT -> definite(RedisCapabilityObservationEvent.Outcome.SUCCESS); + case DEGRADED_UNAVAILABLE -> notApplied(RedisCapabilityObservationEvent.Outcome.UNAVAILABLE); + case INDETERMINATE -> indeterminate(RedisCapabilityObservationEvent.Outcome.INDETERMINATE); + }; + } + + private static RedisCapabilityObserver.Classification definite( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + + private static RedisCapabilityObserver.Classification notApplied( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + } + + private static RedisCapabilityObserver.Classification indeterminate( + RedisCapabilityObservationEvent.Outcome outcome) { + return new RedisCapabilityObserver.Classification( + outcome, RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + try { + refreshCoordinator.close(); + } finally { + policy.close(); + } + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Redis string cache region is closed"); + } + } + + private record KeyMaterial(RedisKeyDigest digest, String keyRevisionKey) { + + private KeyMaterial { + Objects.requireNonNull(digest, "digest must be non-null"); + Objects.requireNonNull(keyRevisionKey, "keyRevisionKey must be non-null"); + } + } + + static final class CacheKeyMaterial implements RedisOwnedPhysicalKeyMaterial { + + private final byte[] encodedKey; + + private CacheKeyMaterial(byte[] encodedKey) { + this.encodedKey = Objects.requireNonNull(encodedKey, "encodedKey must be non-null").clone(); + } + + @Override + public byte[] copyEncodedKey() { + return encodedKey.clone(); + } + } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringValuePrimitives.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringValuePrimitives.java new file mode 100644 index 0000000..0a159e5 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringValuePrimitives.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Bounded string helpers. This internal facade is not a Spring or application capability. */ +final class RedisStringValuePrimitives { + + private final RedisPrimitiveCatalog catalog; + private final RedisPrimitiveExecutor executor; + + RedisStringValuePrimitives(RedisPrimitiveCatalog catalog, RedisPrimitiveCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.executor = new RedisPrimitiveExecutor(catalog, commands); + } + + RedisPrimitiveKey key(String slot, String identity) { + return catalog.keyFactory(RedisPrimitiveId.STRING_GET).key(slot, identity); + } + + RedisPrimitiveValue value(String value) { + return RedisPrimitiveValue.utf8( + value, catalog.descriptor(RedisPrimitiveId.STRING_SET_PX).maximumValueBytes()); + } + + RedisPrimitiveReply get(RedisPrimitiveKey key) { + return executor.execute( + RedisPrimitiveId.STRING_GET, List.of(key), RedisPrimitiveInvocation.NoArguments.INSTANCE); + } + + RedisPrimitiveReply multiGet(List keys) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.STRING_MGET); + List validated = descriptor.validateKeys(keys); + if (validated.size() > 4) { + throw new IllegalArgumentException("bounded MGET supports at most four keys"); + } + ArrayList padded = new ArrayList<>(validated); + while (padded.size() < 4) { + padded.add(validated.getFirst()); + } + return executor.execute( + RedisPrimitiveId.STRING_MGET, + List.copyOf(padded), + new RedisPrimitiveInvocation.MgetArguments( + validated.size(), descriptor.maximumResultBytes(), descriptor.maximumValueBytes())); + } + + RedisPrimitiveMutationResult set( + RedisPrimitiveKey key, RedisPrimitiveValue value, Duration timeToLive) { + return write( + key, + value, + timeToLive, + RedisPrimitiveId.STRING_SET_PX, + RedisPrimitiveInvocation.WriteCondition.ALWAYS); + } + + RedisPrimitiveMutationResult setIfAbsent( + RedisPrimitiveKey key, RedisPrimitiveValue value, Duration timeToLive) { + return write( + key, + value, + timeToLive, + RedisPrimitiveId.STRING_SET_NX_PX, + RedisPrimitiveInvocation.WriteCondition.IF_ABSENT); + } + + RedisPrimitiveMutationResult replace( + RedisPrimitiveKey key, RedisPrimitiveValue value, Duration timeToLive) { + return write( + key, + value, + timeToLive, + RedisPrimitiveId.STRING_SET_XX_PX, + RedisPrimitiveInvocation.WriteCondition.IF_PRESENT); + } + + RedisPrimitiveMutationResult compareSetAbsent( + RedisPrimitiveKey key, RedisPrimitiveValue newValue, Duration timeToLive) { + return executor.mutate( + RedisPrimitiveId.STRING_COMPARE_SET, + List.of(key), + new RedisPrimitiveInvocation.CompareSetArguments( + RedisPrimitiveInvocation.CompareSetArguments.ExpectedKind.ABSENT, + null, + newValue, + timeToLive)); + } + + RedisPrimitiveMutationResult compareSetValue( + RedisPrimitiveKey key, + RedisPrimitiveValue expected, + RedisPrimitiveValue newValue, + Duration timeToLive) { + return executor.mutate( + RedisPrimitiveId.STRING_COMPARE_SET, + List.of(key), + new RedisPrimitiveInvocation.CompareSetArguments( + RedisPrimitiveInvocation.CompareSetArguments.ExpectedKind.VALUE, + expected, + newValue, + timeToLive)); + } + + RedisPrimitiveMutationResult compareDelete(RedisPrimitiveKey key, RedisPrimitiveValue expected) { + return executor.mutate( + RedisPrimitiveId.STRING_COMPARE_DELETE, + List.of(key), + new RedisPrimitiveInvocation.AtomicArguments(List.of(expected))); + } + + private RedisPrimitiveMutationResult write( + RedisPrimitiveKey key, + RedisPrimitiveValue value, + Duration timeToLive, + RedisPrimitiveId id, + RedisPrimitiveInvocation.WriteCondition condition) { + return executor.mutate( + id, List.of(key), new RedisPrimitiveInvocation.ExpiringWrite(value, timeToLive, condition)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredCommands.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredCommands.java new file mode 100644 index 0000000..fe632c3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredCommands.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Narrow command surface for bounded structured Redis programs. */ +interface RedisStructuredCommands { + + String loadCatalogProgram(RedisCatalogProgramInvocation invocation); + + RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutor.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutor.java new file mode 100644 index 0000000..9cf0036 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutor.java @@ -0,0 +1,134 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +/** Executes and fail-closed parses compatible v1 and replay-aware v2 rate-limit replies. */ +final class RedisStructuredProgramExecutor implements RedisRateProgramExecutor { + + private static final long MAXIMUM_EXACT_LUA_INTEGER = 9_007_199_254_740_991L; + + private final RedisProgramCatalog catalog; + private final RedisStructuredCommands commands; + + RedisStructuredProgramExecutor(RedisProgramCatalog catalog, RedisStructuredCommands commands) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.commands = Objects.requireNonNull(commands, "commands must be non-null"); + } + + @Override + public RedisRateProgramReply execute(RedisCatalogProgramInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + RedisProgramDescriptor descriptor = invocation.descriptor(); + if (catalog.descriptor(descriptor.id()) != descriptor) { + throw new IllegalArgumentException("Redis program descriptor is not owned by this catalog"); + } + List result = RedisScriptRecovery.evalMulti(commands, invocation); + return parse(descriptor, result); + } + + private static RedisRateProgramReply parse( + RedisProgramDescriptor descriptor, List fields) { + if (fields == null || fields.size() != descriptor.replyFieldCount()) { + throw incompatible(descriptor); + } + for (byte[] field : fields) { + boundedReply(field, descriptor.maximumReplyFieldBytes(), descriptor); + } + String statusText = ascii(fields.getFirst(), descriptor); + if (!descriptor.statuses().contains(statusText)) { + throw new RedisProgramCompatibilityException(descriptor.id(), statusText); + } + RedisRateProgramStatus status; + try { + status = RedisRateProgramStatus.valueOf(statusText); + } catch (IllegalArgumentException exception) { + throw new RedisProgramCompatibilityException(descriptor.id(), statusText); + } + boolean replayAware = descriptor.replyFieldCount() == 8; + RedisRateProgramDecision decision = + replayAware + ? decision(fields.get(1), descriptor) + : switch (status) { + case ALLOWED -> RedisRateProgramDecision.ALLOWED; + case DENIED -> RedisRateProgramDecision.DENIED; + default -> RedisRateProgramDecision.NONE; + }; + validateDecision(status, decision, descriptor); + int firstNumber = replayAware ? 2 : 1; + return new RedisRateProgramReply( + status, + decision, + unsigned(fields.get(firstNumber), descriptor), + unsigned(fields.get(firstNumber + 1), descriptor), + unsigned(fields.get(firstNumber + 2), descriptor), + unsigned(fields.get(firstNumber + 3), descriptor), + unsigned(fields.get(firstNumber + 4), descriptor), + unsigned(fields.get(firstNumber + 5), descriptor)); + } + + private static RedisRateProgramDecision decision( + byte[] encoded, RedisProgramDescriptor descriptor) { + String value = ascii(encoded, descriptor); + try { + return RedisRateProgramDecision.valueOf(value); + } catch (IllegalArgumentException exception) { + throw incompatible(descriptor); + } + } + + private static void validateDecision( + RedisRateProgramStatus status, + RedisRateProgramDecision decision, + RedisProgramDescriptor descriptor) { + boolean valid = + switch (status) { + case ALLOWED -> decision == RedisRateProgramDecision.ALLOWED; + case DENIED -> decision == RedisRateProgramDecision.DENIED; + case DEDUP_REPLAY -> decision != RedisRateProgramDecision.NONE; + case CLOCK_UNSAFE, STATE_INCOMPATIBLE, INVALID -> + decision == RedisRateProgramDecision.NONE; + }; + if (!valid) { + throw incompatible(descriptor); + } + } + + private static long unsigned(byte[] encoded, RedisProgramDescriptor descriptor) { + String value = ascii(encoded, descriptor); + if (!value.matches("0|[1-9][0-9]{0,15}")) { + throw incompatible(descriptor); + } + try { + long parsed = Long.parseLong(value); + if (parsed > MAXIMUM_EXACT_LUA_INTEGER) { + throw incompatible(descriptor); + } + return parsed; + } catch (NumberFormatException exception) { + throw incompatible(descriptor); + } + } + + private static String ascii(byte[] value, RedisProgramDescriptor descriptor) { + for (byte character : value) { + if (character < 0x20 || character > 0x7e) { + throw incompatible(descriptor); + } + } + return new String(value, StandardCharsets.US_ASCII); + } + + private static void boundedReply( + byte[] value, int maximumBytes, RedisProgramDescriptor descriptor) { + if (value == null || value.length < 1 || value.length > maximumBytes) { + throw incompatible(descriptor); + } + } + + private static RedisProgramCompatibilityException incompatible( + RedisProgramDescriptor descriptor) { + return new RedisProgramCompatibilityException(descriptor.id(), ""); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTemporaryConnectionException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTemporaryConnectionException.java new file mode 100644 index 0000000..6aeff29 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTemporaryConnectionException.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +/** Sanitized marker for a connection or PING outage that is safe for optional recovery. */ +final class RedisTemporaryConnectionException extends IllegalStateException { + + RedisTemporaryConnectionException() { + super("Redis topology is temporarily unavailable"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntime.java new file mode 100644 index 0000000..dae9eaa --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntime.java @@ -0,0 +1,1130 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import io.lettuce.core.AbstractRedisClient; +import io.lettuce.core.GeoArgs; +import io.lettuce.core.GeoSearch; +import io.lettuce.core.GeoWithin; +import io.lettuce.core.KeyValue; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.RedisCommandTimeoutException; +import io.lettuce.core.RedisConnectionException; +import io.lettuce.core.RedisConnectionStateListener; +import io.lettuce.core.ScoredValue; +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.SetArgs; +import io.lettuce.core.api.StatefulConnection; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.cluster.RedisClusterClient; +import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; +import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands; +import io.lettuce.core.pubsub.RedisPubSubListener; +import io.lettuce.core.pubsub.StatefulRedisPubSubConnection; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.channels.ClosedChannelException; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.net.ssl.SSLException; + +/** + * Package-private topology bridge exposing only the adapter's semantic binary command contracts. + */ +final class RedisTopologyCommandRuntime implements RedisRoutableCommandRuntime { + + private static final Set DIRECT_PRIMITIVES = + Set.of( + RedisPrimitiveId.STRING_SET_PX, + RedisPrimitiveId.STRING_SET_NX_PX, + RedisPrimitiveId.STRING_SET_XX_PX, + RedisPrimitiveId.COUNTER_READ, + RedisPrimitiveId.HASH_GET, + RedisPrimitiveId.HASH_MGET, + RedisPrimitiveId.HASH_DELETE_FIELDS, + RedisPrimitiveId.SET_CONTAINS, + RedisPrimitiveId.SET_REMOVE, + RedisPrimitiveId.SET_CARDINALITY, + RedisPrimitiveId.ZSET_REMOVE, + RedisPrimitiveId.ZSET_COUNT, + RedisPrimitiveId.ZSET_RANK_PAGE, + RedisPrimitiveId.ZSET_SCORE_PAGE, + RedisPrimitiveId.LIST_POP, + RedisPrimitiveId.BITMAP_GET, + RedisPrimitiveId.BITMAP_SET, + RedisPrimitiveId.BITMAP_COUNT_FIXED_RANGE, + RedisPrimitiveId.HLL_ADD, + RedisPrimitiveId.HLL_COUNT, + RedisPrimitiveId.HLL_MERGE_SAME_SLOT, + RedisPrimitiveId.GEO_SEARCH); + + private final String deploymentId; + private final RedisRouteIdentity routeIdentity; + private final AbstractRedisClient client; + private final StatefulConnection connection; + private final RedisClusterAsyncCommands commands; + private final RedisLettuceUris credentialOwner; + private final RedisClientRuntimeSettings settings; + private final PubSubConnector pubSubConnector; + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisTopologyCommandRuntime( + String deploymentId, + AbstractRedisClient client, + StatefulConnection connection, + RedisClusterAsyncCommands commands, + RedisRouteIdentity routeIdentity, + RedisLettuceUris credentialOwner, + RedisClientRuntimeSettings settings, + PubSubConnector pubSubConnector) { + this.deploymentId = deploymentId; + this.routeIdentity = + routeIdentity == null ? RedisRouteIdentity.opaqueRuntime(this) : routeIdentity; + this.client = client; + this.connection = connection; + this.commands = commands; + this.credentialOwner = credentialOwner; + this.settings = settings; + this.pubSubConnector = pubSubConnector; + } + + static RedisRoutableCommandRuntime connect( + RedisDeploymentSettings deployment, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + RedisCredentialMaterialProvider credentialProvider, + RedisTrustMaterialProvider trustProvider, + Clock clock) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(settings, "settings must be non-null"); + Objects.requireNonNull(credentialProvider, "credentialProvider must be non-null"); + Objects.requireNonNull(trustProvider, "trustProvider must be non-null"); + Objects.requireNonNull(clock, "clock must be non-null"); + if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) { + RedisSentinelRuntimeConnector connector = + new DefaultRedisSentinelRuntimeConnector( + settings, maximumBulkBytes, credentialProvider, trustProvider, clock); + RedisSentinelDiscoveredRoute discoveredRoute = connector.discover(sentinel); + return connector.connect(sentinel, discoveredRoute); + } + return connectNonSentinel( + deployment, settings, maximumBulkBytes, credentialProvider, trustProvider, clock); + } + + static RedisRoutableCommandRuntime connect( + RedisDeploymentSettings deployment, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + RedisCredentialMaterialProvider credentialProvider, + RedisTrustMaterialProvider trustProvider, + Clock clock, + RedisSentinelRuntimeConnector sentinelConnector) { + Objects.requireNonNull(deployment, "deployment must be non-null"); + Objects.requireNonNull(settings, "settings must be non-null"); + Objects.requireNonNull(credentialProvider, "credentialProvider must be non-null"); + Objects.requireNonNull(trustProvider, "trustProvider must be non-null"); + Objects.requireNonNull(clock, "clock must be non-null"); + Objects.requireNonNull(sentinelConnector, "sentinelConnector must be non-null"); + if (deployment instanceof RedisDeploymentSettings.Sentinel sentinel) { + RedisSentinelDiscoveredRoute discoveredRoute = sentinelConnector.discover(sentinel); + return sentinelConnector.connect(sentinel, discoveredRoute); + } + return connectNonSentinel( + deployment, settings, maximumBulkBytes, credentialProvider, trustProvider, clock); + } + + private static RedisRoutableCommandRuntime connectNonSentinel( + RedisDeploymentSettings deployment, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + RedisCredentialMaterialProvider credentialProvider, + RedisTrustMaterialProvider trustProvider, + Clock clock) { + io.lettuce.core.SslOptions dataTls = + new RedisSslOptionsFactory(trustProvider, clock) + .create(deployment.dataTls(), settings.tlsHandshakeTimeout()); + return new RedisLettuceUriFactory(credentialProvider, clock) + .mapOwnedUris( + deployment, + settings, + uris -> + switch (uris) { + case RedisLettuceUris.Standalone standalone -> + openStandalone( + deployment.deploymentId(), + standalone, + settings, + maximumBulkBytes, + dataTls); + case RedisLettuceUris.Cluster cluster -> + openCluster( + deployment.deploymentId(), cluster, settings, maximumBulkBytes, dataTls); + case RedisLettuceUris.SentinelDiscovery ignored -> + throw new IllegalStateException( + "Redis Sentinel discovery must use its split connector"); + case RedisLettuceUris.SentinelData ignored -> + throw new IllegalStateException( + "Redis Sentinel data must use its split connector"); + }); + } + + static RedisTopologyCommandRuntime openSentinelData( + String deploymentId, + io.lettuce.core.RedisURI directDataUri, + RedisRouteIdentity routeIdentity, + RedisLettuceUris.SentinelData credentialOwner, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + io.lettuce.core.SslOptions dataTls) { + return openStandalone( + deploymentId, + directDataUri, + routeIdentity, + credentialOwner, + settings, + maximumBulkBytes, + dataTls); + } + + private static RedisTopologyCommandRuntime openStandalone( + String deploymentId, + RedisLettuceUris.Standalone uris, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + io.lettuce.core.SslOptions sslOptions) { + return openStandalone( + deploymentId, uris.dataUri(), null, uris, settings, maximumBulkBytes, sslOptions); + } + + private static RedisTopologyCommandRuntime openStandalone( + String deploymentId, + io.lettuce.core.RedisURI dataUri, + RedisRouteIdentity routeIdentity, + RedisLettuceUris credentialOwner, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + io.lettuce.core.SslOptions sslOptions) { + RedisClient client = RedisClient.create(dataUri); + StatefulRedisConnection connection = null; + try { + client.setOptions(new RedisLettuceClientOptionsFactory().clientOptions(settings, sslOptions)); + long deadline = deadline(settings.overallTimeout()); + connection = + awaitConnect( + client.connectAsync(new RedisBoundedByteArrayCodec(maximumBulkBytes), dataUri), + boundedByRemaining(settings.acquireTimeout(), deadline)); + connection.setTimeout(settings.commandTimeout()); + RedisClusterAsyncCommands commands = connection.async(); + awaitConnect(commands.ping(), boundedByRemaining(settings.commandTimeout(), deadline)); + return new RedisTopologyCommandRuntime( + deploymentId, + client, + connection, + commands, + routeIdentity, + credentialOwner, + settings, + () -> + await( + client.connectPubSubAsync( + new RedisBoundedByteArrayCodec(maximumBulkBytes), dataUri), + settings.acquireTimeout(), + false)); + } catch (RuntimeException exception) { + closeFailed(client, connection, settings.shutdownTimeout()); + throw sanitizeConnectionFailure(exception); + } + } + + private static RedisTopologyCommandRuntime openCluster( + String deploymentId, + RedisLettuceUris.Cluster uris, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + io.lettuce.core.SslOptions sslOptions) { + RedisClusterClient client = RedisClusterClient.create(uris.seedUris()); + StatefulRedisClusterConnection connection = null; + try { + client.setOptions( + new RedisLettuceClientOptionsFactory().clusterClientOptions(settings, sslOptions)); + long deadline = deadline(settings.overallTimeout()); + connection = + awaitConnect( + client.connectAsync(new RedisBoundedByteArrayCodec(maximumBulkBytes)), + boundedByRemaining(settings.acquireTimeout(), deadline)); + connection.setTimeout(settings.commandTimeout()); + RedisClusterAsyncCommands commands = connection.async(); + awaitConnect(commands.ping(), boundedByRemaining(settings.commandTimeout(), deadline)); + return new RedisTopologyCommandRuntime( + deploymentId, + client, + connection, + commands, + null, + uris, + settings, + () -> + await( + client.connectPubSubAsync(new RedisBoundedByteArrayCodec(maximumBulkBytes)), + settings.acquireTimeout(), + false)); + } catch (RuntimeException exception) { + closeFailed(client, connection, settings.shutdownTimeout()); + throw sanitizeConnectionFailure(exception); + } + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public RedisRouteIdentity routeIdentity() { + return routeIdentity; + } + + @Override + public void probe(Duration timeout) { + await(commands.ping(), timeout, false); + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return defensive( + await( + commands.get(RedisPhysicalKey.WireCodec.copy(key)), settings.overallTimeout(), false)); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + Objects.requireNonNull(timeToLive, "timeToLive must be non-null"); + String result = + await( + commands.set( + RedisPhysicalKey.WireCodec.copy(key), + value.copyEncoded(), + SetArgs.Builder.px(timeToLive.toMillis())), + settings.overallTimeout(), + true); + if (!"OK".equals(result)) { + throw commandFailure(true); + } + } + + @Override + public long delete(RedisPhysicalKey key) { + return await( + commands.del(RedisPhysicalKey.WireCodec.copy(key)), settings.overallTimeout(), true); + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + Objects.requireNonNull(invocation, "invocation must be non-null"); + RedisPrimitiveDescriptor descriptor = invocation.descriptor(); + if (descriptor.programId() != null) { + return new RedisPrimitiveProgramDispatcher(this).execute(invocation); + } + if (!supportsDirect(descriptor.id())) { + throw new IllegalStateException( + "Catalog direct primitive is missing runtime dispatch: " + descriptor.id()); + } + byte[] key = RedisPhysicalKey.WireCodec.copy(invocation.keys().getFirst().physicalKey()); + Duration timeout = invocation.remainingDeadline(); + try { + return switch (descriptor.id()) { + case STRING_GET -> { + byte[] value = defensive(await(commands.get(key), timeout, false)); + yield value == null + ? RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.MISSING, + List.of(), + OptionalLong.empty(), + "") + : RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PRESENT, + List.of(RedisPrimitiveValue.copyOf(value, descriptor.maximumValueBytes())), + OptionalLong.empty(), + ""); + } + case STRING_SET_PX, STRING_SET_NX_PX, STRING_SET_XX_PX -> { + RedisPrimitiveInvocation.ExpiringWrite write = + (RedisPrimitiveInvocation.ExpiringWrite) invocation.arguments(); + SetArgs args = SetArgs.Builder.px(write.timeToLive().value()); + args = + switch (write.condition()) { + case ALWAYS -> args; + case IF_ABSENT -> args.nx(); + case IF_PRESENT -> args.xx(); + }; + String result = + await(commands.set(key, write.value().copyEncoded(), args), timeout, true); + yield RedisPrimitiveReply.bounded( + descriptor, + "OK".equals(result) + ? RedisPrimitiveReply.Status.APPLIED + : RedisPrimitiveReply.Status.CONDITION_NOT_MET, + List.of(), + OptionalLong.of("OK".equals(result) ? 1 : 0), + ""); + } + case COUNTER_READ -> { + byte[] value = defensive(await(commands.get(key), timeout, false)); + if (value == null) { + yield RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.MISSING, + List.of(), + OptionalLong.empty(), + ""); + } + String encoded = new String(value, java.nio.charset.StandardCharsets.US_ASCII); + if (!encoded.matches("-?(0|[1-9][0-9]{0,18})") || "-0".equals(encoded)) { + yield RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.MALFORMED_VALUE, + List.of(), + OptionalLong.empty(), + ""); + } + try { + yield RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PRESENT, + List.of(), + OptionalLong.of(Long.parseLong(encoded)), + ""); + } catch (NumberFormatException exception) { + yield RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.MALFORMED_VALUE, + List.of(), + OptionalLong.empty(), + ""); + } + } + case HASH_GET -> { + RedisPrimitiveInvocation.BinaryArguments fields = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + yield valueReply( + descriptor, + defensive( + await( + commands.hget(key, fields.values().getFirst().copyEncoded()), + timeout, + false))); + } + case HASH_MGET -> { + RedisPrimitiveInvocation.BinaryArguments fields = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + List> values = + await( + commands.hmget( + key, + fields.values().stream() + .map(RedisPrimitiveValue::copyEncoded) + .toArray(byte[][]::new)), + timeout, + false); + yield RedisPrimitiveReply.bulk( + descriptor, + RedisPrimitiveReply.Status.OK, + values.stream() + .map( + value -> + value.hasValue() + ? RedisPrimitiveElementResult.present( + RedisPrimitiveValue.copyOf( + value.getValue(), descriptor.maximumValueBytes())) + : RedisPrimitiveElementResult.missing()) + .toList()); + } + case HASH_DELETE_FIELDS -> { + RedisPrimitiveInvocation.BinaryArguments fields = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.REMOVED, + await( + commands.hdel( + key, + fields.values().stream() + .map(RedisPrimitiveValue::copyEncoded) + .toArray(byte[][]::new)), + timeout, + true)); + } + case SET_CONTAINS -> { + RedisPrimitiveInvocation.BinaryArguments members = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + boolean member = + await( + commands.sismember(key, members.values().getFirst().copyEncoded()), + timeout, + false); + yield RedisPrimitiveReply.bounded( + descriptor, + member ? RedisPrimitiveReply.Status.MEMBER : RedisPrimitiveReply.Status.NOT_MEMBER, + List.of(), + OptionalLong.empty(), + ""); + } + case SET_REMOVE -> { + RedisPrimitiveInvocation.BinaryArguments members = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.REMOVED, + await( + commands.srem( + key, + members.values().stream() + .map(RedisPrimitiveValue::copyEncoded) + .toArray(byte[][]::new)), + timeout, + true)); + } + case SET_CARDINALITY -> + numberReply( + descriptor, + RedisPrimitiveReply.Status.COUNT, + await(commands.scard(key), timeout, false)); + case ZSET_REMOVE -> { + RedisPrimitiveInvocation.BinaryArguments members = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.REMOVED, + await( + commands.zrem( + key, + members.values().stream() + .map(RedisPrimitiveValue::copyEncoded) + .toArray(byte[][]::new)), + timeout, + true)); + } + case ZSET_COUNT -> { + RedisPrimitiveInvocation.ScoreRangeArguments range = + (RedisPrimitiveInvocation.ScoreRangeArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.COUNT, + await( + commands.zcount(key, range.minimum().canonical(), range.maximum().canonical()), + timeout, + false)); + } + case ZSET_RANK_PAGE -> { + RedisPrimitiveInvocation.RangeArguments range = + (RedisPrimitiveInvocation.RangeArguments) invocation.arguments(); + yield valuesReply( + descriptor, await(commands.zrange(key, range.first(), range.last()), timeout, false)); + } + case ZSET_SCORE_PAGE -> { + RedisPrimitiveInvocation.ScoreRangeArguments range = + (RedisPrimitiveInvocation.ScoreRangeArguments) invocation.arguments(); + List> values = + await( + commands.zrangebyscoreWithScores( + key, + range.minimum().canonical(), + range.maximum().canonical(), + range.offset(), + range.limit().value()), + timeout, + false); + List encoded = new java.util.ArrayList<>(values.size() * 2); + for (ScoredValue value : values) { + encoded.add( + RedisPrimitiveValue.copyOf(value.getValue(), descriptor.maximumMemberBytes())); + encoded.add( + RedisPrimitiveValue.utf8( + RedisSortedSetScore.of(Double.toString(value.getScore())).canonical(), 128)); + } + yield RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PAGE, + encoded, + OptionalLong.of(values.size()), + ""); + } + case LIST_POP -> + valueReply(descriptor, defensive(await(commands.rpop(key), timeout, true))); + case BITMAP_GET -> { + RedisPrimitiveInvocation.BitmapArguments bitmap = + (RedisPrimitiveInvocation.BitmapArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.PRESENT, + await(commands.getbit(key, bitmap.first().value()), timeout, false)); + } + case BITMAP_SET -> { + RedisPrimitiveInvocation.BitmapArguments bitmap = + (RedisPrimitiveInvocation.BitmapArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.APPLIED, + await(commands.setbit(key, bitmap.first().value(), bitmap.bit()), timeout, true)); + } + case BITMAP_COUNT_FIXED_RANGE -> { + RedisPrimitiveInvocation.BitmapCountArguments bitmap = + (RedisPrimitiveInvocation.BitmapCountArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.COUNT, + await( + commands.bitcount(key, bitmap.first().value(), bitmap.last().value()), + timeout, + false)); + } + case HLL_ADD -> { + RedisPrimitiveInvocation.BinaryArguments elements = + (RedisPrimitiveInvocation.BinaryArguments) invocation.arguments(); + yield numberReply( + descriptor, + RedisPrimitiveReply.Status.APPLIED, + await( + commands.pfadd( + key, + elements.values().stream() + .map(RedisPrimitiveValue::copyEncoded) + .toArray(byte[][]::new)), + timeout, + true)); + } + case HLL_COUNT -> + numberReply( + descriptor, + RedisPrimitiveReply.Status.COUNT, + await(commands.pfcount(key), timeout, false)); + case HLL_MERGE_SAME_SLOT -> { + byte[][] sourceKeys = + invocation.keys().stream() + .skip(1) + .map(value -> RedisPhysicalKey.WireCodec.copy(value.physicalKey())) + .toArray(byte[][]::new); + String result = await(commands.pfmerge(key, sourceKeys), timeout, true); + yield RedisPrimitiveReply.bounded( + descriptor, + "OK".equals(result) + ? RedisPrimitiveReply.Status.APPLIED + : RedisPrimitiveReply.Status.CORRUPT_AFTER_WRITE, + List.of(), + OptionalLong.of("OK".equals(result) ? 1 : 0), + ""); + } + case GEO_SEARCH -> { + RedisPrimitiveInvocation.GeoArguments geo = + (RedisPrimitiveInvocation.GeoArguments) invocation.arguments(); + GeoArgs args = new GeoArgs().withDistance().withCount(geo.limit().value()); + args = + geo.sort() == RedisPrimitiveInvocation.GeoArguments.Sort.ASCENDING + ? args.asc() + : args.desc(); + List> values = + await( + commands.geosearch( + key, + GeoSearch.fromCoordinates( + geo.coordinate().longitude(), geo.coordinate().latitude()), + geo.shape() == RedisPrimitiveInvocation.GeoArguments.Shape.RADIUS + ? GeoSearch.byRadius(geo.firstMeters(), GeoArgs.Unit.m) + : GeoSearch.byBox(geo.firstMeters(), geo.secondMeters(), GeoArgs.Unit.m), + args), + timeout, + false); + List encoded = new java.util.ArrayList<>(values.size() * 2); + for (GeoWithin value : values) { + encoded.add( + RedisPrimitiveValue.copyOf(value.getMember(), descriptor.maximumMemberBytes())); + encoded.add( + RedisPrimitiveValue.utf8( + java.math.BigDecimal.valueOf(value.getDistance()) + .stripTrailingZeros() + .toPlainString(), + 128)); + } + yield RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PAGE, + encoded, + OptionalLong.of(values.size()), + ""); + } + default -> + throw new IllegalStateException( + "Catalog direct primitive is missing runtime dispatch: " + descriptor.id()); + }; + } catch (PrimitiveWrongTypeException ignored) { + return RedisPrimitiveReply.bounded( + descriptor, RedisPrimitiveReply.Status.WRONG_TYPE, List.of(), OptionalLong.empty(), ""); + } + } + + static boolean supportsDirect(RedisPrimitiveId id) { + return DIRECT_PRIMITIVES.contains(id); + } + + private static RedisPrimitiveReply valueReply(RedisPrimitiveDescriptor descriptor, byte[] value) { + return value == null + ? RedisPrimitiveReply.bounded( + descriptor, RedisPrimitiveReply.Status.MISSING, List.of(), OptionalLong.empty(), "") + : RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PRESENT, + List.of(RedisPrimitiveValue.copyOf(value, descriptor.maximumValueBytes())), + OptionalLong.empty(), + ""); + } + + private static RedisPrimitiveReply valuesReply( + RedisPrimitiveDescriptor descriptor, List values) { + return RedisPrimitiveReply.bulk( + descriptor, + RedisPrimitiveReply.Status.PAGE, + values.stream() + .map( + value -> + RedisPrimitiveElementResult.present( + RedisPrimitiveValue.copyOf(value, descriptor.maximumMemberBytes()))) + .toList()); + } + + private static RedisPrimitiveReply numberReply( + RedisPrimitiveDescriptor descriptor, RedisPrimitiveReply.Status status, long value) { + if (value < 0) { + throw new IllegalStateException("Redis returned an impossible negative primitive count"); + } + return RedisPrimitiveReply.bounded(descriptor, status, List.of(), OptionalLong.of(value), ""); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram(RedisCatalogProgramInvocation invocation) { + boolean mutation = + invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE + && invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI; + ScriptOutputType outputType = + invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + || invocation.replyShape() + == RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_MULTI + ? ScriptOutputType.MULTI + : ScriptOutputType.VALUE; + Object result = + await( + commands.evalsha( + RedisScriptRecovery.sha1( + RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)), + outputType, + RedisCatalogProgramInvocation.WireCodec.keysArray(invocation), + RedisCatalogProgramInvocation.WireCodec.argumentsArray(invocation)), + invocation.boundedTimeout(settings.overallTimeout()), + mutation); + if (outputType == ScriptOutputType.MULTI) { + @SuppressWarnings("unchecked") + List fields = (List) result; + return RedisCatalogProgramReply.multi(defensive(fields)); + } + return RedisCatalogProgramReply.value(defensive((byte[]) result)); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return await( + commands.scriptLoad(RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)), + invocation.boundedTimeout(settings.overallTimeout()), + true); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return await(commands.publish(copy(channel), copy(message)), settings.overallTimeout(), true); + } + + @Override + public synchronized Subscription subscribe(byte[] channel, Listener listener) { + Objects.requireNonNull(listener, "listener must be non-null"); + if (closed.get()) { + throw new IllegalStateException("Redis topology runtime is closed"); + } + byte[] safeChannel = copy(channel); + StatefulRedisPubSubConnection pubSubConnection = pubSubConnector.connect(); + RuntimeSubscription subscription = + new RuntimeSubscription(pubSubConnection, safeChannel, listener); + try { + subscription.start(); + subscriptions.add(subscription); + return subscription; + } catch (RuntimeException exception) { + subscription.close(); + throw new IllegalStateException("Redis invalidation subscription failed"); + } + } + + @Override + public synchronized void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + boolean cleanupFailed = false; + for (RuntimeSubscription subscription : subscriptions) { + try { + subscription.close(); + } catch (RuntimeException ignored) { + cleanupFailed = true; + } + } + subscriptions.clear(); + try { + connection.close(); + } catch (RuntimeException ignored) { + cleanupFailed = true; + } + try { + client.shutdown(Duration.ZERO, settings.shutdownTimeout()); + } catch (RuntimeException ignored) { + cleanupFailed = true; + } + try { + credentialOwner.close(); + } catch (RuntimeException ignored) { + cleanupFailed = true; + } + if (cleanupFailed) { + throw new IllegalStateException("Redis topology runtime close failed"); + } + } + + private static T await(Future future, Duration timeout, boolean mutation) { + try { + return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS); + } catch (InterruptedException exception) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw commandFailure(mutation); + } catch (TimeoutException exception) { + future.cancel(true); + throw classifyCommandFailure(exception, mutation); + } catch (ExecutionException exception) { + if (exception.getCause() instanceof io.lettuce.core.RedisNoScriptException) { + throw new RedisNoScriptException(); + } + if (valueTooLarge(exception.getCause())) { + throw new RedisValueTooLargeException(); + } + if (wrongType(exception.getCause())) { + throw new PrimitiveWrongTypeException(); + } + throw classifyCommandFailure(exception.getCause(), mutation); + } + } + + private static T awaitConnect(Future future, Duration timeout) { + try { + return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS); + } catch (InterruptedException exception) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw connectFailure(); + } catch (TimeoutException exception) { + future.cancel(true); + throw new RedisTemporaryConnectionException(); + } catch (ExecutionException exception) { + throw sanitizeConnectionFailure(exception.getCause()); + } + } + + private static long deadline(Duration timeout) { + return System.nanoTime() + timeout.toNanos(); + } + + private static Duration boundedByRemaining(Duration timeout, long deadline) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + throw new RedisTemporaryConnectionException(); + } + Duration remainingDuration = Duration.ofNanos(remaining); + return timeout.compareTo(remainingDuration) < 0 ? timeout : remainingDuration; + } + + private static RedisCommandFailureException commandFailure(boolean mutation) { + return commandFailure(mutation, RedisCommandFailureException.Kind.UNAVAILABLE); + } + + private static RedisCommandFailureException commandFailure( + boolean mutation, RedisCommandFailureException.Kind kind) { + return new RedisCommandFailureException( + kind, + mutation + ? RedisCommandFailureException.Certainty.INDETERMINATE + : RedisCommandFailureException.Certainty.NOT_APPLIED, + "Redis topology command failed within its bounded deadline", + null); + } + + static RedisCommandFailureException classifyCommandFailure(Throwable failure, boolean mutation) { + RedisCommandFailureException.Kind kind = + aclDenied(failure) + ? RedisCommandFailureException.Kind.ACL_DENIED + : RedisCommandFailureException.Kind.UNAVAILABLE; + RedisCommandFailureException.RecoveryHint recoveryHint = + kind == RedisCommandFailureException.Kind.UNAVAILABLE && topologyTransportFailure(failure) + ? RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL + : RedisCommandFailureException.RecoveryHint.NONE; + return new RedisCommandFailureException( + kind, + mutation + ? RedisCommandFailureException.Certainty.INDETERMINATE + : RedisCommandFailureException.Certainty.NOT_APPLIED, + recoveryHint, + "Redis topology command failed within its bounded deadline", + null); + } + + private static boolean topologyTransportFailure(Throwable failure) { + List causes = boundedCauses(failure); + for (Throwable cause : causes) { + if (cause instanceof RedisCommandExecutionException + || cause instanceof SSLException + || authenticationFailure(cause)) { + return false; + } + } + for (Throwable cause : causes) { + if (cause instanceof RedisConnectionException + || cause instanceof RedisCommandTimeoutException + || cause instanceof ConnectException + || cause instanceof UnknownHostException + || cause instanceof SocketTimeoutException + || cause instanceof ClosedChannelException + || cause instanceof TimeoutException) { + return true; + } + } + return false; + } + + private static boolean aclDenied(Throwable cause) { + if (!(cause instanceof io.lettuce.core.RedisCommandExecutionException)) { + return false; + } + String message = cause.getMessage(); + return message != null && message.startsWith("NOPERM"); + } + + private static boolean wrongType(Throwable cause) { + if (!(cause instanceof io.lettuce.core.RedisCommandExecutionException)) { + return false; + } + String message = cause.getMessage(); + return message != null && message.startsWith("WRONGTYPE"); + } + + private static boolean valueTooLarge(Throwable cause) { + if (!(cause instanceof io.lettuce.core.RedisCommandExecutionException)) { + return false; + } + String message = cause.getMessage(); + return message != null && message.contains("CA_VALUE_TOO_LARGE"); + } + + static RuntimeException sanitizeConnectionFailure(Throwable failure) { + if (failure instanceof RedisTemporaryConnectionException temporary) { + return temporary; + } + List causes = boundedCauses(failure); + for (Throwable cause : causes) { + if (cause instanceof SSLException || authenticationFailure(cause)) { + return connectFailure(); + } + } + for (Throwable cause : causes) { + if (cause instanceof ConnectException + || cause instanceof UnknownHostException + || cause instanceof SocketTimeoutException + || cause instanceof ClosedChannelException + || cause instanceof TimeoutException + || cause instanceof RedisCommandTimeoutException) { + return new RedisTemporaryConnectionException(); + } + } + return connectFailure(); + } + + private static List boundedCauses(Throwable failure) { + if (failure == null) { + return List.of(); + } + List result = new java.util.ArrayList<>(8); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Throwable current = failure; + while (current != null && result.size() < 16 && seen.add(current)) { + result.add(current); + current = current.getCause(); + } + return List.copyOf(result); + } + + private static boolean authenticationFailure(Throwable failure) { + if (!(failure instanceof RedisCommandExecutionException)) { + return false; + } + String message = failure.getMessage(); + return message != null + && (message.startsWith("WRONGPASS") + || message.startsWith("NOAUTH") + || message.startsWith("NOPERM")); + } + + private static IllegalStateException connectFailure() { + return new IllegalStateException("Redis topology connect or probe failed"); + } + + static void closeFailed( + AbstractRedisClient client, StatefulConnection connection, Duration shutdownTimeout) { + if (connection != null) { + try { + awaitConnect(connection.closeAsync(), shutdownTimeout); + } catch (RuntimeException ignored) { + // Preserve the original sanitized connect/probe failure. + } + } + try { + awaitConnect( + client.shutdownAsync(0, shutdownTimeout.toNanos(), TimeUnit.NANOSECONDS), + shutdownTimeout); + } catch (RuntimeException ignored) { + // Preserve the original sanitized connect/probe failure. + } + } + + private static byte[] copy(byte[] value) { + return Objects.requireNonNull(value, "Redis binary value must be non-null").clone(); + } + + private static final class PrimitiveWrongTypeException extends RuntimeException {} + + private static byte[] defensive(byte[] value) { + return value == null ? null : value.clone(); + } + + private static List defensive(List values) { + return values == null + ? null + : values.stream().map(value -> value == null ? null : value.clone()).toList(); + } + + @FunctionalInterface + interface PubSubConnector { + + StatefulRedisPubSubConnection connect(); + } + + private final class RuntimeSubscription implements Subscription { + + private final StatefulRedisPubSubConnection pubSubConnection; + private final byte[] channel; + private final Listener listener; + private final AtomicBoolean subscriptionClosed = new AtomicBoolean(); + + private RuntimeSubscription( + StatefulRedisPubSubConnection pubSubConnection, + byte[] channel, + Listener listener) { + this.pubSubConnection = pubSubConnection; + this.channel = channel; + this.listener = listener; + } + + private void start() { + pubSubConnection.addListener( + new RedisConnectionStateListener() { + @Override + public void onRedisDisconnected(io.lettuce.core.RedisChannelHandler connection) { + if (!subscriptionClosed.get()) { + notifyDisconnected(); + } + } + }); + pubSubConnection.addListener( + new RedisPubSubListener<>() { + @Override + public void message(byte[] receivedChannel, byte[] message) { + if (!subscriptionClosed.get() && Arrays.equals(channel, receivedChannel)) { + notifyMessage(message); + } + } + + @Override + public void message(byte[] pattern, byte[] receivedChannel, byte[] message) {} + + @Override + public void subscribed(byte[] subscribedChannel, long count) {} + + @Override + public void psubscribed(byte[] pattern, long count) {} + + @Override + public void unsubscribed(byte[] unsubscribedChannel, long count) {} + + @Override + public void punsubscribed(byte[] pattern, long count) {} + }); + await(pubSubConnection.async().subscribe(channel), settings.commandTimeout(), false); + } + + private void notifyMessage(byte[] message) { + try { + listener.onMessage(copy(message)); + } catch (RuntimeException ignored) { + // A consumer callback must not terminate Lettuce's event loop. + } + } + + private void notifyDisconnected() { + try { + listener.onDisconnected(); + } catch (RuntimeException ignored) { + // A consumer callback must not terminate Lettuce's event loop. + } + } + + @Override + public void close() { + if (subscriptionClosed.compareAndSet(false, true)) { + subscriptions.remove(this); + try { + await(pubSubConnection.async().unsubscribe(channel), settings.commandTimeout(), false); + } catch (RuntimeException ignored) { + // Closing the connection is the final cancellation path. + } + try { + pubSubConnection.close(); + } catch (RuntimeException ignored) { + throw new IllegalStateException("Redis topology runtime close failed"); + } + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTtlMillis.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTtlMillis.java new file mode 100644 index 0000000..f4b719b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTtlMillis.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.util.Objects; + +/** Validated, lossless primitive TTL carried to Redis as integer milliseconds. */ +record RedisTtlMillis(long value) { + + private static final long MAXIMUM_VALUE = Duration.ofDays(31).toMillis(); + + RedisTtlMillis { + if (value < 1 || value > MAXIMUM_VALUE) { + throw new IllegalArgumentException("primitive TTL milliseconds must be in 1..2678400000"); + } + } + + static RedisTtlMillis from(Duration duration) { + Objects.requireNonNull(duration, "primitive TTL must be non-null"); + long milliseconds; + try { + milliseconds = duration.toMillis(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("primitive TTL exceeds supported range", exception); + } + if (!duration.equals(Duration.ofMillis(milliseconds))) { + throw new IllegalArgumentException("primitive TTL must be an exact positive millisecond"); + } + return new RedisTtlMillis(milliseconds); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSession.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSession.java new file mode 100644 index 0000000..140ac09 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSession.java @@ -0,0 +1,155 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.session.MapSession; +import org.springframework.session.Session; + +/** Spring Session view with adapter-private revision, absolute-expiry and rotation state. */ +final class RedisVersionedSession implements Session { + + private final MapSession delegate; + private final Clock clock; + private String persistedId; + private long revision; + private final Instant absoluteExpiresAt; + private boolean newlyCreated; + private boolean changed; + + RedisVersionedSession( + String id, + Instant createdAt, + Instant lastAccessedAt, + Duration idleTimeout, + Instant absoluteExpiresAt, + long revision, + Map attributes, + boolean newlyCreated, + Clock clock) { + this.delegate = new MapSession(id); + this.clock = Objects.requireNonNull(clock, "clock"); + this.delegate.setCreationTime(createdAt); + this.delegate.setLastAccessedTime(lastAccessedAt); + this.delegate.setMaxInactiveInterval(idleTimeout); + attributes.forEach(this.delegate::setAttribute); + this.persistedId = id; + this.revision = revision; + this.absoluteExpiresAt = absoluteExpiresAt; + this.newlyCreated = newlyCreated; + } + + String persistedId() { + return persistedId; + } + + long revision() { + return revision; + } + + Instant absoluteExpiresAt() { + return absoluteExpiresAt; + } + + boolean newlyCreated() { + return newlyCreated; + } + + boolean rotated() { + return !persistedId.equals(getId()); + } + + boolean changed() { + return changed; + } + + RedisSessionSnapshot snapshot(long resultingRevision) { + java.util.LinkedHashMap attributes = new java.util.LinkedHashMap<>(); + for (String name : getAttributeNames()) { + attributes.put(name, getAttribute(name)); + } + return new RedisSessionSnapshot( + getCreationTime(), + getLastAccessedTime(), + absoluteExpiresAt, + getMaxInactiveInterval(), + resultingRevision, + attributes); + } + + void persisted(long resultingRevision) { + persistedId = getId(); + revision = resultingRevision; + newlyCreated = false; + changed = false; + } + + @Override + public String getId() { + return delegate.getId(); + } + + @Override + public String changeSessionId() { + changed = true; + return delegate.changeSessionId(); + } + + @Override + public T getAttribute(String attributeName) { + return delegate.getAttribute(attributeName); + } + + @Override + public Set getAttributeNames() { + return delegate.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + delegate.setAttribute(attributeName, attributeValue); + changed = true; + } + + @Override + public void removeAttribute(String attributeName) { + delegate.removeAttribute(attributeName); + changed = true; + } + + @Override + public Instant getCreationTime() { + return delegate.getCreationTime(); + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + delegate.setLastAccessedTime(lastAccessedTime); + } + + @Override + public Instant getLastAccessedTime() { + return delegate.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + delegate.setMaxInactiveInterval(interval); + changed = true; + } + + @Override + public Duration getMaxInactiveInterval() { + return delegate.getMaxInactiveInterval(); + } + + @Override + public boolean isExpired() { + Instant now = clock.instant(); + return !now.isBefore(getLastAccessedTime().plus(getMaxInactiveInterval())) + || !now.isBefore(absoluteExpiresAt); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepository.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepository.java new file mode 100644 index 0000000..786258c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepository.java @@ -0,0 +1,302 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import org.springframework.session.SessionRepository; + +/** + * Spring Session repository enforcing absolute expiry, bounded touch and tombstone + * anti-resurrection. + */ +final class RedisVersionedSessionRepository implements SessionRepository { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final VersionedRedisSessionStore store; + private final RedisSessionEnvelopeCodec codec; + private final Clock clock; + private final Duration idleTimeout; + private final Duration absoluteLifetime; + private final Duration touchInterval; + private final Duration tombstoneTimeToLive; + + RedisVersionedSessionRepository( + VersionedRedisSessionStore store, + RedisSessionEnvelopeCodec codec, + Clock clock, + Duration idleTimeout, + Duration absoluteLifetime, + Duration touchInterval, + Duration tombstoneTimeToLive) { + this.store = Objects.requireNonNull(store, "store"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.idleTimeout = positive(idleTimeout, "idleTimeout"); + this.absoluteLifetime = positive(absoluteLifetime, "absoluteLifetime"); + this.touchInterval = positive(touchInterval, "touchInterval"); + this.tombstoneTimeToLive = positive(tombstoneTimeToLive, "tombstoneTimeToLive"); + if (touchInterval.compareTo(idleTimeout) >= 0) { + throw new IllegalArgumentException("touchInterval must be shorter than idleTimeout"); + } + } + + @Override + public RedisVersionedSession createSession() { + Instant now = clock.instant(); + return new RedisVersionedSession( + newSessionId(), + now, + now, + idleTimeout, + now.plus(absoluteLifetime), + 1, + Map.of(), + true, + clock); + } + + @Override + public void save(RedisVersionedSession session) { + Objects.requireNonNull(session, "session"); + Instant now = clock.instant(); + if (!now.isBefore(session.absoluteExpiresAt())) { + revoke(session.persistedId(), session.revision()); + throw new RedisSessionConflictException("Redis session absolute lifetime elapsed"); + } + if (session.newlyCreated()) { + byte[] payload = codec.encode(session.snapshot(1)); + SessionCreateOutcome outcome = + invoke( + () -> + store.create( + new SessionCreateCommand( + session.getId(), + payload, + 1, + session.absoluteExpiresAt(), + session.getLastAccessedTime(), + session.getMaxInactiveInterval(), + store.newMutationAttempt()))); + if (outcome == SessionCreateOutcome.CREATED + || outcome == SessionCreateOutcome.ALREADY_CREATED_SAME_OPERATION) { + session.persisted(1); + return; + } + if (outcome == SessionCreateOutcome.INDETERMINATE + || outcome == SessionCreateOutcome.UNAVAILABLE) { + throw unavailable(); + } + throw conflict("create", outcome); + } + long newRevision = session.revision() + 1; + byte[] payload = codec.encode(session.snapshot(newRevision)); + if (session.rotated()) { + SessionRotateOutcome outcome = + invoke( + () -> + store.rotate( + new SessionRotateCommand( + session.persistedId(), + session.getId(), + payload, + session.revision(), + newRevision, + session.absoluteExpiresAt(), + session.getLastAccessedTime(), + session.getMaxInactiveInterval(), + tombstoneTimeToLive, + store.newMutationAttempt()))); + if (outcome == SessionRotateOutcome.ROTATED + || outcome == SessionRotateOutcome.ALREADY_ROTATED_SAME_OPERATION) { + session.persisted(newRevision); + return; + } + if (outcome == SessionRotateOutcome.INDETERMINATE + || outcome == SessionRotateOutcome.UNAVAILABLE) { + throw unavailable(); + } + throw conflict("rotate", outcome); + } + if (!session.changed()) { + touch(session, now); + return; + } + SessionSaveOutcome outcome = + invoke( + () -> + store.saveIfLive( + new SessionSaveCommand( + session.getId(), + payload, + session.revision(), + newRevision, + session.absoluteExpiresAt(), + session.getLastAccessedTime(), + session.getMaxInactiveInterval(), + store.newMutationAttempt()))); + if (outcome == SessionSaveOutcome.SAVED + || outcome == SessionSaveOutcome.ALREADY_SAVED_SAME_OPERATION) { + session.persisted(newRevision); + return; + } + if (outcome == SessionSaveOutcome.INDETERMINATE || outcome == SessionSaveOutcome.UNAVAILABLE) { + throw unavailable(); + } + throw conflict("save", outcome); + } + + @Override + public RedisVersionedSession findById(String id) { + Instant now = clock.instant(); + SessionInspectionOutcome outcome = + invoke(() -> store.inspect(new SessionInspectionCommand(id, now))); + if (outcome instanceof SessionInspectionOutcome.Absent + || outcome instanceof SessionInspectionOutcome.Tombstoned + || outcome instanceof SessionInspectionOutcome.AbsoluteExpired) { + return null; + } + if (outcome instanceof SessionInspectionOutcome.Unavailable) { + throw unavailable(); + } + SessionInspectionOutcome.Live live = (SessionInspectionOutcome.Live) outcome; + RedisSessionSnapshot snapshot; + try { + snapshot = codec.decode(live.payload()); + if (snapshot.revision() != live.revision() + || !snapshot.absoluteExpiresAt().equals(live.absoluteExpiresAt())) { + throw new RedisSessionCorruptPayloadException(); + } + } catch (RedisSessionCorruptPayloadException exception) { + revoke(id, live.revision()); + return null; + } + Instant effectiveLastAccessedAt = live.lastAccessedAt(); + if (effectiveLastAccessedAt.isBefore(snapshot.createdAt()) + || effectiveLastAccessedAt.isAfter(now)) { + revoke(id, live.revision()); + return null; + } + if (!now.isBefore(snapshot.absoluteExpiresAt()) + || !now.isBefore(effectiveLastAccessedAt.plus(snapshot.idleTimeout()))) { + revoke(id, live.revision()); + return null; + } + RedisVersionedSession session = + new RedisVersionedSession( + id, + snapshot.createdAt(), + effectiveLastAccessedAt, + snapshot.idleTimeout(), + snapshot.absoluteExpiresAt(), + snapshot.revision(), + snapshot.attributes(), + false, + clock); + if (!now.isBefore(effectiveLastAccessedAt.plus(touchInterval))) { + touch(session, now); + session.setLastAccessedTime(now); + } + return session; + } + + @Override + public void deleteById(String id) { + // Explicit logout must dominate a concurrent stale save. Revision zero is the adapter-private + // force-revoke marker handled atomically by the tombstone script. + revoke(id, 0); + } + + private void touch(RedisVersionedSession session, Instant now) { + SessionTouchOutcome outcome = + invoke( + () -> + store.touchIfLive( + new SessionTouchCommand( + session.getId(), + session.revision(), + now, + session.absoluteExpiresAt(), + session.getMaxInactiveInterval(), + touchInterval, + store.newMutationAttempt()))); + if (outcome == SessionTouchOutcome.TOUCHED + || outcome == SessionTouchOutcome.ALREADY_TOUCHED_SAME_OPERATION + || outcome == SessionTouchOutcome.TOUCH_NOT_DUE) { + return; + } + if (outcome == SessionTouchOutcome.INDETERMINATE + || outcome == SessionTouchOutcome.UNAVAILABLE) { + throw unavailable(); + } + throw conflict("touch", outcome); + } + + private void revoke(String id, long revision) { + SessionRevokeOutcome outcome = + invoke( + () -> + store.tombstoneAndDelete( + new SessionRevokeCommand( + id, revision, tombstoneTimeToLive, store.newMutationAttempt()))); + if (outcome == SessionRevokeOutcome.REVOKED_AND_DELETED + || outcome == SessionRevokeOutcome.TOMBSTONED_ABSENT + || outcome == SessionRevokeOutcome.ALREADY_REVOKED_SAME_OPERATION) { + return; + } + if (outcome == SessionRevokeOutcome.INDETERMINATE + || outcome == SessionRevokeOutcome.UNAVAILABLE) { + throw unavailable(); + } + throw conflict("revoke", outcome); + } + + private static T invoke(java.util.function.Supplier operation) { + try { + return operation.get(); + } catch (RedisCommandFailureException exception) { + throw unavailable(); + } + } + + private static RedisSessionConflictException conflict(String operation, Object outcome) { + return new RedisSessionConflictException( + "Redis session " + operation + " rejected: " + outcome); + } + + private static RedisSessionUnavailableException unavailable() { + return new RedisSessionUnavailableException(); + } + + private static Duration positive(Duration value, String field) { + Objects.requireNonNull(value, field); + if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + return value; + } + + private static String newSessionId() { + byte[] random = new byte[32]; + RANDOM.nextBytes(random); + return Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } +} + +final class RedisSessionConflictException extends RuntimeException { + + RedisSessionConflictException(String message) { + super(message); + } +} + +final class RedisSessionUnavailableException extends RuntimeException { + + RedisSessionUnavailableException() { + super("Redis session repository unavailable"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/SafeRedisCapabilityObservationPort.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/SafeRedisCapabilityObservationPort.java new file mode 100644 index 0000000..b8e10e0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/SafeRedisCapabilityObservationPort.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Objects; + +final class SafeRedisCapabilityObservationPort implements RedisCapabilityObservationPort { + + private final RedisCapabilityObservationPort delegate; + + SafeRedisCapabilityObservationPort(RedisCapabilityObservationPort delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate must be non-null"); + } + + @Override + public void observe(RedisCapabilityObservationEvent.Event event) { + Objects.requireNonNull(event, "event must be non-null"); + try { + delegate.observe(event); + } catch (RuntimeException ignored) { + // Command results and certainty remain authoritative when diagnostics fail. + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStore.java new file mode 100644 index 0000000..6c68e12 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStore.java @@ -0,0 +1,345 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** Adapter-internal mutation contract; no Spring Session or web type crosses this boundary. */ +interface VersionedRedisSessionStore { + + SessionMutationAttempt newMutationAttempt(); + + SessionCreateOutcome create(SessionCreateCommand command); + + SessionInspectionOutcome inspect(SessionInspectionCommand command); + + SessionSaveOutcome saveIfLive(SessionSaveCommand command); + + SessionTouchOutcome touchIfLive(SessionTouchCommand command); + + SessionRevokeOutcome tombstoneAndDelete(SessionRevokeCommand command); + + SessionRotateOutcome rotate(SessionRotateCommand command); +} + +record SessionMutationAttempt(String operationId) { + + SessionMutationAttempt(String operationId) { + this.operationId = boundedText(operationId, "operationId", 8, 128); + } + + private static String boundedText(String value, String field, int minimum, int maximum) { + if (value == null + || value.length() < minimum + || value.length() > maximum + || value.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " must contain bounded safe text"); + } + return value; + } +} + +record SessionCreateCommand( + String sessionId, + byte[] payload, + long newRevision, + Instant absoluteExpiresAt, + Instant lastAccessedAt, + Duration idleTimeout, + SessionMutationAttempt attempt) { + + SessionCreateCommand( + String sessionId, + byte[] payload, + long newRevision, + Instant absoluteExpiresAt, + Instant lastAccessedAt, + Duration idleTimeout, + SessionMutationAttempt attempt) { + this.sessionId = SessionCommandValidation.sessionId(sessionId); + this.payload = SessionCommandValidation.payload(payload); + SessionCommandValidation.positiveRevision(newRevision, "newRevision"); + this.newRevision = newRevision; + this.absoluteExpiresAt = Objects.requireNonNull(absoluteExpiresAt, "absoluteExpiresAt"); + this.lastAccessedAt = Objects.requireNonNull(lastAccessedAt, "lastAccessedAt"); + this.idleTimeout = SessionCommandValidation.positive(idleTimeout, "idleTimeout"); + this.attempt = Objects.requireNonNull(attempt, "attempt"); + } + + @Override + public byte[] payload() { + return payload.clone(); + } +} + +record SessionInspectionCommand(String sessionId, Instant now) { + + SessionInspectionCommand(String sessionId, Instant now) { + this.sessionId = SessionCommandValidation.sessionId(sessionId); + this.now = Objects.requireNonNull(now, "now"); + } +} + +record SessionSaveCommand( + String sessionId, + byte[] payload, + long expectedRevision, + long newRevision, + Instant absoluteExpiresAt, + Instant lastAccessedAt, + Duration idleTimeout, + SessionMutationAttempt attempt) { + + SessionSaveCommand( + String sessionId, + byte[] payload, + long expectedRevision, + long newRevision, + Instant absoluteExpiresAt, + Instant lastAccessedAt, + Duration idleTimeout, + SessionMutationAttempt attempt) { + this.sessionId = SessionCommandValidation.sessionId(sessionId); + this.payload = SessionCommandValidation.payload(payload); + SessionCommandValidation.positiveRevision(expectedRevision, "expectedRevision"); + SessionCommandValidation.positiveRevision(newRevision, "newRevision"); + if (newRevision <= expectedRevision) { + throw new IllegalArgumentException("newRevision must exceed expectedRevision"); + } + this.expectedRevision = expectedRevision; + this.newRevision = newRevision; + this.absoluteExpiresAt = Objects.requireNonNull(absoluteExpiresAt, "absoluteExpiresAt"); + this.lastAccessedAt = Objects.requireNonNull(lastAccessedAt, "lastAccessedAt"); + this.idleTimeout = SessionCommandValidation.positive(idleTimeout, "idleTimeout"); + this.attempt = Objects.requireNonNull(attempt, "attempt"); + } + + @Override + public byte[] payload() { + return payload.clone(); + } +} + +record SessionTouchCommand( + String sessionId, + long expectedRevision, + Instant now, + Instant absoluteExpiresAt, + Duration idleTimeout, + Duration touchInterval, + SessionMutationAttempt attempt) { + + SessionTouchCommand( + String sessionId, + long expectedRevision, + Instant now, + Instant absoluteExpiresAt, + Duration idleTimeout, + Duration touchInterval, + SessionMutationAttempt attempt) { + this.sessionId = SessionCommandValidation.sessionId(sessionId); + SessionCommandValidation.positiveRevision(expectedRevision, "expectedRevision"); + this.expectedRevision = expectedRevision; + this.now = Objects.requireNonNull(now, "now"); + this.absoluteExpiresAt = Objects.requireNonNull(absoluteExpiresAt, "absoluteExpiresAt"); + this.idleTimeout = SessionCommandValidation.positive(idleTimeout, "idleTimeout"); + this.touchInterval = SessionCommandValidation.positive(touchInterval, "touchInterval"); + this.attempt = Objects.requireNonNull(attempt, "attempt"); + } +} + +record SessionRevokeCommand( + String sessionId, + long expectedRevision, + Duration tombstoneTimeToLive, + SessionMutationAttempt attempt) { + + SessionRevokeCommand( + String sessionId, + long expectedRevision, + Duration tombstoneTimeToLive, + SessionMutationAttempt attempt) { + this.sessionId = SessionCommandValidation.sessionId(sessionId); + if (expectedRevision < 0) { + throw new IllegalArgumentException("expectedRevision must be non-negative"); + } + this.expectedRevision = expectedRevision; + this.tombstoneTimeToLive = + SessionCommandValidation.positive(tombstoneTimeToLive, "tombstoneTimeToLive"); + this.attempt = Objects.requireNonNull(attempt, "attempt"); + } +} + +record SessionRotateCommand( + String oldSessionId, + String newSessionId, + byte[] payload, + long expectedRevision, + long newRevision, + Instant absoluteExpiresAt, + Instant lastAccessedAt, + Duration idleTimeout, + Duration tombstoneTimeToLive, + SessionMutationAttempt attempt) { + + SessionRotateCommand( + String oldSessionId, + String newSessionId, + byte[] payload, + long expectedRevision, + long newRevision, + Instant absoluteExpiresAt, + Instant lastAccessedAt, + Duration idleTimeout, + Duration tombstoneTimeToLive, + SessionMutationAttempt attempt) { + this.oldSessionId = SessionCommandValidation.sessionId(oldSessionId); + this.newSessionId = SessionCommandValidation.sessionId(newSessionId); + if (this.oldSessionId.equals(this.newSessionId)) { + throw new IllegalArgumentException("rotation requires a distinct session ID"); + } + this.payload = SessionCommandValidation.payload(payload); + SessionCommandValidation.positiveRevision(expectedRevision, "expectedRevision"); + SessionCommandValidation.positiveRevision(newRevision, "newRevision"); + if (newRevision <= expectedRevision) { + throw new IllegalArgumentException("newRevision must exceed expectedRevision"); + } + this.expectedRevision = expectedRevision; + this.newRevision = newRevision; + this.absoluteExpiresAt = Objects.requireNonNull(absoluteExpiresAt, "absoluteExpiresAt"); + this.lastAccessedAt = Objects.requireNonNull(lastAccessedAt, "lastAccessedAt"); + this.idleTimeout = SessionCommandValidation.positive(idleTimeout, "idleTimeout"); + this.tombstoneTimeToLive = + SessionCommandValidation.positive(tombstoneTimeToLive, "tombstoneTimeToLive"); + this.attempt = Objects.requireNonNull(attempt, "attempt"); + } + + @Override + public byte[] payload() { + return payload.clone(); + } +} + +enum SessionCreateOutcome { + CREATED, + ALREADY_CREATED_SAME_OPERATION, + EXISTS_CONFLICT, + TOMBSTONED, + ABSOLUTE_EXPIRED, + INDETERMINATE, + UNAVAILABLE +} + +sealed interface SessionInspectionOutcome { + + record Live(byte[] payload, long revision, Instant absoluteExpiresAt, Instant lastAccessedAt) + implements SessionInspectionOutcome { + + public Live(byte[] payload, long revision, Instant absoluteExpiresAt, Instant lastAccessedAt) { + this.payload = SessionCommandValidation.payload(payload); + SessionCommandValidation.positiveRevision(revision, "revision"); + this.revision = revision; + this.absoluteExpiresAt = Objects.requireNonNull(absoluteExpiresAt, "absoluteExpiresAt"); + this.lastAccessedAt = Objects.requireNonNull(lastAccessedAt, "lastAccessedAt"); + } + + @Override + public byte[] payload() { + return payload.clone(); + } + } + + record Tombstoned() implements SessionInspectionOutcome {} + + record Absent() implements SessionInspectionOutcome {} + + record AbsoluteExpired() implements SessionInspectionOutcome {} + + record Unavailable() implements SessionInspectionOutcome {} +} + +enum SessionSaveOutcome { + SAVED, + ALREADY_SAVED_SAME_OPERATION, + ABSENT, + STALE_REVISION, + MUTATION_CONFLICT, + TOMBSTONED, + ABSOLUTE_EXPIRED, + INDETERMINATE, + UNAVAILABLE +} + +enum SessionTouchOutcome { + TOUCHED, + ALREADY_TOUCHED_SAME_OPERATION, + TOUCH_NOT_DUE, + ABSENT, + STALE_REVISION, + MUTATION_CONFLICT, + TOMBSTONED, + ABSOLUTE_EXPIRED, + INDETERMINATE, + UNAVAILABLE +} + +enum SessionRevokeOutcome { + REVOKED_AND_DELETED, + TOMBSTONED_ABSENT, + ALREADY_REVOKED_SAME_OPERATION, + STALE_REVISION, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE +} + +enum SessionRotateOutcome { + ROTATED, + ALREADY_ROTATED_SAME_OPERATION, + OLD_ABSENT, + STALE_REVISION, + OLD_TOMBSTONED, + NEW_ID_CONFLICT, + ABSOLUTE_EXPIRED, + INDETERMINATE, + UNAVAILABLE +} + +final class SessionCommandValidation { + + private static final int MAXIMUM_PAYLOAD_BYTES = 1_048_576; + + private SessionCommandValidation() {} + + static String sessionId(String value) { + if (value == null + || value.length() < 16 + || value.length() > 256 + || !value.matches("[A-Za-z0-9._-]+")) { + throw new IllegalArgumentException("sessionId must be bounded opaque text"); + } + return value; + } + + static byte[] payload(byte[] value) { + Objects.requireNonNull(value, "payload"); + if (value.length < 1 || value.length > MAXIMUM_PAYLOAD_BYTES) { + throw new IllegalArgumentException("payload must be non-empty and bounded"); + } + return value.clone(); + } + + static void positiveRevision(long value, String field) { + if (value < 1) { + throw new IllegalArgumentException(field + " must be positive"); + } + } + + static Duration positive(Duration value, String field) { + Objects.requireNonNull(value, field); + if (value.isZero() || value.isNegative() || value.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java new file mode 100644 index 0000000..c10f917 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettings.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +import java.util.List; +import java.util.Objects; + +/** Validated immutable runtime settings for exactly one Redis topology. */ +public sealed interface RedisDeploymentSettings + permits RedisDeploymentSettings.Standalone, + RedisDeploymentSettings.Sentinel, + RedisDeploymentSettings.Cluster { + + String deploymentId(); + + int database(); + + Authentication dataAuthentication(); + + Tls dataTls(); + + record Standalone( + String deploymentId, + int database, + List endpoints, + Authentication dataAuthentication, + Tls dataTls) + implements RedisDeploymentSettings { + + public Standalone { + Objects.requireNonNull(deploymentId, "deploymentId must be non-null"); + endpoints = List.copyOf(endpoints); + Objects.requireNonNull(dataAuthentication, "dataAuthentication must be non-null"); + Objects.requireNonNull(dataTls, "dataTls must be non-null"); + } + } + + record Sentinel( + String deploymentId, + int database, + String masterName, + List sentinelEndpoints, + List dataEndpoints, + Authentication sentinelAuthentication, + Tls sentinelTls, + Authentication dataAuthentication, + Tls dataTls) + implements RedisDeploymentSettings { + + public Sentinel { + Objects.requireNonNull(deploymentId, "deploymentId must be non-null"); + Objects.requireNonNull(masterName, "masterName must be non-null"); + sentinelEndpoints = List.copyOf(sentinelEndpoints); + dataEndpoints = List.copyOf(dataEndpoints); + Objects.requireNonNull(sentinelAuthentication, "sentinelAuthentication must be non-null"); + Objects.requireNonNull(sentinelTls, "sentinelTls must be non-null"); + Objects.requireNonNull(dataAuthentication, "dataAuthentication must be non-null"); + Objects.requireNonNull(dataTls, "dataTls must be non-null"); + } + } + + record Cluster( + String deploymentId, + int database, + List seedEndpoints, + Authentication dataAuthentication, + Tls dataTls) + implements RedisDeploymentSettings { + + public Cluster { + Objects.requireNonNull(deploymentId, "deploymentId must be non-null"); + seedEndpoints = List.copyOf(seedEndpoints); + Objects.requireNonNull(dataAuthentication, "dataAuthentication must be non-null"); + Objects.requireNonNull(dataTls, "dataTls must be non-null"); + } + } + + record Endpoint(String host, int port) { + + public Endpoint { + Objects.requireNonNull(host, "host must be non-null"); + } + } + + record Authentication(String username, String passwordReference) { + + public Authentication { + Objects.requireNonNull(username, "username must be non-null"); + Objects.requireNonNull(passwordReference, "passwordReference must be non-null"); + } + } + + record Tls(boolean enabled, boolean verifyHostname, String trustBundleReference) { + + public Tls { + Objects.requireNonNull(trustBundleReference, "trustBundleReference must be non-null"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java new file mode 100644 index 0000000..16f8376 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactory.java @@ -0,0 +1,342 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Compiles bindable provider definitions into validated immutable topology settings. */ +public final class RedisDeploymentSettingsFactory { + + private static final int MAXIMUM_DATABASE = 15; + private static final int MAXIMUM_ENDPOINT_HOST_LENGTH = 253; + private static final int MAXIMUM_ID_LENGTH = 128; + private static final int MAXIMUM_SECRET_REFERENCE_LENGTH = 512; + + public Map compileRegistered(RedisProviderSettings properties) { + Objects.requireNonNull(properties, "properties must be non-null"); + Map compiled = new LinkedHashMap<>(); + properties + .deployments() + .forEach( + (deploymentId, deployment) -> { + String normalizedId = + boundedText(deploymentId, "Redis deployment ID", MAXIMUM_ID_LENGTH); + if (!normalizedId.equals(deploymentId)) { + throw new IllegalArgumentException( + "Redis deployment ID must not have surrounding whitespace: " + normalizedId); + } + if (deployment == null) { + throw new IllegalArgumentException( + "Redis deployment must be configured: " + normalizedId); + } + compiled.put(normalizedId, compile(normalizedId, deployment)); + }); + return Map.copyOf(compiled); + } + + public Map compileActive(RedisProviderSettings properties) { + Objects.requireNonNull(properties, "properties must be non-null"); + return compileActive(properties, properties.roles().keySet()); + } + + /** + * Compiles only the role bindings selected for runtime activation. + * + *

Provider definitions remain registered candidates and are always structurally validated, + * while unselected role bindings remain inert and do not resolve material or open a client. + */ + public Map compileActive( + RedisProviderSettings properties, Set selectedRoles) { + Objects.requireNonNull(properties, "properties must be non-null"); + Objects.requireNonNull(selectedRoles, "selectedRoles must be non-null"); + Map registered = compileRegistered(properties); + if (selectedRoles.isEmpty()) { + return Map.of(); + } + + Map active = new EnumMap<>(RedisRole.class); + Map deploymentOwners = new LinkedHashMap<>(); + for (RedisRole role : selectedRoles) { + RedisRoleBinding binding = properties.roles().get(role); + if (binding == null) { + throw new IllegalStateException( + "Selected Redis capability requires a canonical Redis " + role + " role binding"); + } + validateRolePolicy(role, binding); + String deploymentId = + boundedText( + binding.deploymentId(), + "Redis deployment reference for role " + role, + MAXIMUM_ID_LENGTH); + RedisDeploymentSettings settings = registered.get(deploymentId); + if (settings == null) { + throw new IllegalArgumentException( + "Redis role " + role + " references unknown deployment " + deploymentId); + } + RedisRole existingRole = deploymentOwners.putIfAbsent(deploymentId, role); + if (existingRole != null) { + throw new IllegalArgumentException( + "Redis roles " + + existingRole + + " and " + + role + + " cannot co-locate on deployment " + + deploymentId); + } + active.put(role, settings); + } + return Map.copyOf(active); + } + + private static void validateRolePolicy(RedisRole role, RedisRoleBinding binding) { + RedisEvictionPolicy eviction = expectedEviction(role, binding.expectedEviction()); + switch (role) { + case CACHE -> { + if (binding.required()) { + throw new IllegalArgumentException("Redis CACHE role must remain optional for readiness"); + } + if (eviction != RedisEvictionPolicy.ALLKEYS_LFU + && eviction != RedisEvictionPolicy.ALLKEYS_LRU) { + throw new IllegalArgumentException( + "Redis CACHE role requires allkeys-lfu or allkeys-lru eviction"); + } + } + case COORDINATION, SESSION -> { + if (!binding.required()) { + throw new IllegalArgumentException("Redis " + role + " role must be required"); + } + if (eviction != RedisEvictionPolicy.NOEVICTION) { + throw new IllegalArgumentException("Redis " + role + " role requires noeviction"); + } + } + default -> throw new IllegalStateException("Unhandled canonical Redis role: " + role); + } + } + + private static RedisEvictionPolicy expectedEviction(RedisRole role, String configured) { + String value = + boundedText( + configured, "Redis expected eviction policy for role " + role, MAXIMUM_ID_LENGTH) + .replace('-', '_') + .toUpperCase(Locale.ROOT); + try { + return RedisEvictionPolicy.valueOf(value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "Redis role " + role + " has unsupported expected eviction policy", exception); + } + } + + private static RedisDeploymentSettings compile( + String deploymentId, RedisProviderSettings.DeploymentProperties properties) { + int configuredBodies = + present(properties.standalone()) + + present(properties.sentinel()) + + present(properties.cluster()); + if (configuredBodies != 1) { + throw new IllegalArgumentException( + "Redis deployment " + deploymentId + " must configure exactly one topology body"); + } + if (properties.topology() == null) { + throw new IllegalArgumentException( + "Redis deployment " + deploymentId + " topology must be configured"); + } + validateTopologyMatch(deploymentId, properties); + validateDatabase(deploymentId, properties.topology(), properties.database()); + + RedisDeploymentSettings.Authentication dataAuthentication = + authentication( + properties.authentication(), "Redis data-node authentication for " + deploymentId); + RedisDeploymentSettings.Tls dataTls = + tls(properties.tls(), "Redis data-node TLS for " + deploymentId); + + return switch (properties.topology()) { + case STANDALONE -> + new RedisDeploymentSettings.Standalone( + deploymentId, + properties.database(), + endpoints( + properties.standalone().endpoints(), + "Redis standalone deployment " + deploymentId, + 1), + dataAuthentication, + dataTls); + case SENTINEL -> sentinel(deploymentId, properties, dataAuthentication, dataTls); + case CLUSTER -> + new RedisDeploymentSettings.Cluster( + deploymentId, + properties.database(), + endpoints( + properties.cluster().endpoints(), "Redis Cluster deployment " + deploymentId, 1), + dataAuthentication, + dataTls); + }; + } + + private static RedisDeploymentSettings.Sentinel sentinel( + String deploymentId, + RedisProviderSettings.DeploymentProperties deployment, + RedisDeploymentSettings.Authentication dataAuthentication, + RedisDeploymentSettings.Tls dataTls) { + RedisProviderSettings.SentinelProperties properties = deployment.sentinel(); + String masterName = + boundedText( + properties.masterName(), + "Redis Sentinel master name for " + deploymentId, + MAXIMUM_ID_LENGTH); + if (masterName.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException( + "Redis Sentinel master name must not contain whitespace: " + deploymentId); + } + RedisDeploymentSettings.Authentication sentinelAuthentication = + authentication( + properties.authentication(), + "Redis Sentinel discovery authentication for " + deploymentId); + if (sentinelAuthentication.equals(dataAuthentication)) { + throw new IllegalArgumentException( + "Redis Sentinel discovery and data-node authentication must be separate: " + + deploymentId); + } + RedisDeploymentSettings.Tls sentinelTls = + tls(properties.tls(), "Redis Sentinel discovery TLS for " + deploymentId); + if (!sentinelTls.enabled() + || !sentinelTls.verifyHostname() + || !dataTls.enabled() + || !dataTls.verifyHostname()) { + throw new IllegalArgumentException( + "Redis Sentinel discovery and data-node TLS must both verify hostnames: " + deploymentId); + } + if (sentinelTls.trustBundleReference().equals(dataTls.trustBundleReference())) { + throw new IllegalArgumentException( + "Redis Sentinel discovery and data-node TLS trust material must be separate: " + + deploymentId); + } + return new RedisDeploymentSettings.Sentinel( + deploymentId, + deployment.database(), + masterName, + endpoints(properties.endpoints(), "Redis Sentinel deployment " + deploymentId, 3), + endpoints( + properties.dataEndpoints(), "Redis Sentinel data-node deployment " + deploymentId, 3), + sentinelAuthentication, + sentinelTls, + dataAuthentication, + dataTls); + } + + private static void validateTopologyMatch( + String deploymentId, RedisProviderSettings.DeploymentProperties properties) { + boolean matches = + switch (properties.topology()) { + case STANDALONE -> properties.standalone() != null; + case SENTINEL -> properties.sentinel() != null; + case CLUSTER -> properties.cluster() != null; + }; + if (!matches) { + throw new IllegalArgumentException( + "Redis deployment " + + deploymentId + + " topology discriminator does not match its configured body"); + } + } + + private static void validateDatabase( + String deploymentId, RedisProviderSettings.Topology topology, int database) { + if (topology == RedisProviderSettings.Topology.CLUSTER && database != 0) { + throw new IllegalArgumentException( + "Redis Cluster deployment " + deploymentId + " must use database 0"); + } + if (database < 0 || database > MAXIMUM_DATABASE) { + throw new IllegalArgumentException( + "Redis deployment " + deploymentId + " database must be in 0..15"); + } + } + + private static List endpoints( + List properties, String field, int minimumCount) { + if (properties == null || properties.size() < minimumCount) { + throw new IllegalArgumentException( + field + " requires at least " + minimumCount + " endpoint(s)"); + } + Set unique = new LinkedHashSet<>(); + for (RedisProviderSettings.EndpointProperties endpoint : properties) { + if (endpoint == null) { + throw new IllegalArgumentException(field + " endpoint must be configured"); + } + String host = + boundedText(endpoint.host(), field + " endpoint host", MAXIMUM_ENDPOINT_HOST_LENGTH) + .toLowerCase(Locale.ROOT); + if (host.chars().anyMatch(Character::isWhitespace) + || host.contains("/") + || host.contains("\\")) { + throw new IllegalArgumentException(field + " endpoint host is invalid"); + } + if (endpoint.port() < 1 || endpoint.port() > 65_535) { + throw new IllegalArgumentException(field + " endpoint port must be in 1..65535"); + } + RedisDeploymentSettings.Endpoint compiled = + new RedisDeploymentSettings.Endpoint(host, endpoint.port()); + if (!unique.add(compiled)) { + throw new IllegalArgumentException(field + " contains a duplicate endpoint: " + host); + } + } + return List.copyOf(unique); + } + + private static RedisDeploymentSettings.Authentication authentication( + RedisProviderSettings.AuthenticationProperties properties, String field) { + if (properties == null) { + throw new IllegalArgumentException(field + " must be configured"); + } + String username = boundedText(properties.username(), field + " username", MAXIMUM_ID_LENGTH); + String passwordReference = + secretReference(properties.passwordReference(), field + " password reference"); + return new RedisDeploymentSettings.Authentication(username, passwordReference); + } + + private static RedisDeploymentSettings.Tls tls( + RedisProviderSettings.TlsProperties properties, String field) { + if (properties == null) { + throw new IllegalArgumentException(field + " must be configured"); + } + String trustBundleReference = + properties.trustBundleReference() == null ? "" : properties.trustBundleReference().trim(); + if (properties.enabled()) { + trustBundleReference = secretReference(trustBundleReference, field + " trust bundle"); + } else if (properties.verifyHostname() || !trustBundleReference.isEmpty()) { + throw new IllegalArgumentException( + field + " disabled mode must not configure hostname verification or trust material"); + } + return new RedisDeploymentSettings.Tls( + properties.enabled(), properties.verifyHostname(), trustBundleReference); + } + + private static String secretReference(String value, String field) { + String reference = boundedText(value, field, MAXIMUM_SECRET_REFERENCE_LENGTH); + if (!reference.startsWith("secret://") || reference.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException(field + " must be a secret:// reference"); + } + return reference; + } + + private static String boundedText(String value, String field, int maximumLength) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must be non-empty"); + } + String normalized = value.trim(); + if (normalized.length() > maximumLength + || normalized.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " is invalid or exceeds its bound"); + } + return normalized; + } + + private static int present(Object value) { + return value == null ? 0 : 1; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisEvictionPolicy.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisEvictionPolicy.java new file mode 100644 index 0000000..bc1817d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisEvictionPolicy.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +/** Operator-attested maxmemory policy expected by one physical Redis role binding. */ +enum RedisEvictionPolicy { + ALLKEYS_LFU, + ALLKEYS_LRU, + NOEVICTION +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettings.java new file mode 100644 index 0000000..6e47547 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettings.java @@ -0,0 +1,212 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** + * Bindable Redis provider definitions and role bindings. + * + *

Registered deployments are connection candidates only. A deployment is active only when a role + * binding references it. + */ +@ConfigurationProperties(prefix = "ca-skeleton.providers.redis") +public record RedisProviderSettings( + Map deployments, + Map roles, + boolean legacyMigrationEnabled, + RuntimeProperties runtime) { + + @ConstructorBinding + public RedisProviderSettings { + deployments = deployments == null ? Map.of() : Map.copyOf(deployments); + roles = roles == null ? Map.of() : Map.copyOf(roles); + runtime = runtime == null ? RuntimeProperties.defaults() : runtime; + } + + public RedisProviderSettings( + Map deployments, Map roles) { + this(deployments, roles, false, null); + } + + public boolean hasActiveRoleBindings() { + return !roles.isEmpty(); + } + + public enum Topology { + STANDALONE, + SENTINEL, + CLUSTER + } + + public record DeploymentProperties( + Topology topology, + StandaloneProperties standalone, + SentinelProperties sentinel, + ClusterProperties cluster, + int database, + AuthenticationProperties authentication, + TlsProperties tls) {} + + public record StandaloneProperties(List endpoints) { + + public StandaloneProperties { + endpoints = endpoints == null ? List.of() : List.copyOf(endpoints); + } + } + + public record SentinelProperties( + String masterName, + List endpoints, + List dataEndpoints, + AuthenticationProperties authentication, + TlsProperties tls) { + + public SentinelProperties { + endpoints = endpoints == null ? List.of() : List.copyOf(endpoints); + dataEndpoints = dataEndpoints == null ? List.of() : List.copyOf(dataEndpoints); + } + } + + public record ClusterProperties(List endpoints) { + + public ClusterProperties { + endpoints = endpoints == null ? List.of() : List.copyOf(endpoints); + } + } + + public record EndpointProperties(String host, int port) {} + + public record AuthenticationProperties(String username, String passwordReference) {} + + public record TlsProperties( + boolean enabled, boolean verifyHostname, String trustBundleReference) {} + + /** Bounded runtime and router controls shared by all canonically role-bound deployments. */ + public record RuntimeProperties( + String clientName, + Duration connectTimeout, + Duration tlsHandshakeTimeout, + Duration acquireTimeout, + Duration commandTimeout, + Duration overallTimeout, + Duration shutdownTimeout, + int maximumQueuedCommands, + int clusterMaximumRedirects, + Duration clusterTopologyRefreshPeriod, + int maximumInFlightCommands, + int maximumCommandBytes, + long maximumInFlightBytes, + Duration routeDrainTimeout, + Duration defaultWriteTtl, + Duration sentinelDiscoveryRefreshPeriod, + Duration semanticProbeMinimumInterval, + Duration semanticProbeMaximumStaleness) { + + public RuntimeProperties { + clientName = defaultText(clientName, "canonical-redis"); + connectTimeout = defaultDuration(connectTimeout, Duration.ofSeconds(2)); + tlsHandshakeTimeout = defaultDuration(tlsHandshakeTimeout, Duration.ofSeconds(3)); + acquireTimeout = defaultDuration(acquireTimeout, Duration.ofSeconds(2)); + commandTimeout = defaultDuration(commandTimeout, Duration.ofSeconds(2)); + overallTimeout = defaultDuration(overallTimeout, Duration.ofSeconds(5)); + shutdownTimeout = defaultDuration(shutdownTimeout, Duration.ofSeconds(3)); + maximumQueuedCommands = maximumQueuedCommands == 0 ? 64 : maximumQueuedCommands; + clusterMaximumRedirects = clusterMaximumRedirects == 0 ? 5 : clusterMaximumRedirects; + clusterTopologyRefreshPeriod = + defaultDuration(clusterTopologyRefreshPeriod, Duration.ofSeconds(30)); + maximumInFlightCommands = maximumInFlightCommands == 0 ? 64 : maximumInFlightCommands; + maximumCommandBytes = maximumCommandBytes == 0 ? 65_536 : maximumCommandBytes; + maximumInFlightBytes = maximumInFlightBytes == 0 ? 4L * 1024 * 1024 : maximumInFlightBytes; + routeDrainTimeout = defaultDuration(routeDrainTimeout, Duration.ofSeconds(6)); + defaultWriteTtl = defaultDuration(defaultWriteTtl, Duration.ofMinutes(5)); + sentinelDiscoveryRefreshPeriod = + defaultDuration(sentinelDiscoveryRefreshPeriod, Duration.ofSeconds(30)); + semanticProbeMinimumInterval = + defaultDuration(semanticProbeMinimumInterval, Duration.ofSeconds(5)); + semanticProbeMaximumStaleness = + defaultDuration(semanticProbeMaximumStaleness, Duration.ofSeconds(15)); + + RedisClientRuntimeSettings clientSettings = + new RedisClientRuntimeSettings( + clientName, + connectTimeout, + tlsHandshakeTimeout, + acquireTimeout, + commandTimeout, + overallTimeout, + shutdownTimeout, + maximumQueuedCommands, + clusterMaximumRedirects, + clusterTopologyRefreshPeriod); + if (maximumInFlightCommands < 1 || maximumInFlightCommands > 4096) { + throw new IllegalArgumentException("Redis maximum in-flight commands must be in 1..4096"); + } + if (maximumCommandBytes < 1024 || maximumCommandBytes > 16_777_216) { + throw new IllegalArgumentException("Redis maximum command bytes must be bounded"); + } + if (maximumInFlightBytes < maximumCommandBytes || maximumInFlightBytes > 268_435_456L) { + throw new IllegalArgumentException( + "Redis maximum in-flight bytes must cover one command and be bounded"); + } + if (routeDrainTimeout.compareTo(clientSettings.overallTimeout().plusMillis(100)) < 0 + || routeDrainTimeout.compareTo(Duration.ofSeconds(30)) > 0) { + throw new IllegalArgumentException( + "Redis route drain timeout must include the overall timeout, a 100ms safety margin," + + " and remain bounded"); + } + if (defaultWriteTtl.isZero() + || defaultWriteTtl.isNegative() + || defaultWriteTtl.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException("Redis default write TTL must be positive and bounded"); + } + if (sentinelDiscoveryRefreshPeriod.compareTo(Duration.ofSeconds(5)) < 0 + || sentinelDiscoveryRefreshPeriod.compareTo(Duration.ofMinutes(5)) > 0) { + throw new IllegalArgumentException( + "Redis Sentinel discovery refresh period must be in 5s..5m"); + } + if (semanticProbeMinimumInterval.compareTo(Duration.ofSeconds(1)) < 0 + || semanticProbeMinimumInterval.compareTo(Duration.ofMinutes(1)) > 0) { + throw new IllegalArgumentException( + "Redis semantic probe minimum interval must be in 1s..60s"); + } + if (semanticProbeMaximumStaleness.compareTo(semanticProbeMinimumInterval) < 0 + || semanticProbeMaximumStaleness.compareTo(Duration.ofMinutes(5)) > 0) { + throw new IllegalArgumentException( + "Redis semantic probe maximum staleness must cover the minimum interval and be at most" + + " 5m"); + } + } + + public RedisClientRuntimeSettings clientSettings() { + return new RedisClientRuntimeSettings( + clientName, + connectTimeout, + tlsHandshakeTimeout, + acquireTimeout, + commandTimeout, + overallTimeout, + shutdownTimeout, + maximumQueuedCommands, + clusterMaximumRedirects, + clusterTopologyRefreshPeriod); + } + + private static RuntimeProperties defaults() { + return new RuntimeProperties( + null, null, null, null, null, null, null, 0, 0, null, 0, 0, 0, null, null, null, null, + null); + } + + private static Duration defaultDuration(Duration value, Duration fallback) { + return value == null ? fallback : value; + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java new file mode 100644 index 0000000..a117530 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRole.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +/** Physical Redis workload roles with intentionally different failure and eviction guarantees. */ +public enum RedisRole { + CACHE, + COORDINATION, + SESSION +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java new file mode 100644 index 0000000..3f9484d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisRoleBinding.java @@ -0,0 +1,4 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +/** Binds one activated Redis role to one registered physical deployment. */ +public record RedisRoleBinding(String deploymentId, boolean required, String expectedEviction) {} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java index b15cee9..578725b 100644 --- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/key/RedisKeyBuilder.java @@ -26,6 +26,37 @@ public final class RedisKeyBuilder { digest.slotTag(), digest.resourceDigest(), namespace.kind()); + validateMaximumBytes(namespace, key); + return key; + } + + /** + * Builds an entry key under one captured region generation and per-key revision while retaining + * the stable logical-resource Cluster hash slot. + */ + public static String buildVersioned( + RedisKeyNamespace namespace, + RedisKeyDigest digest, + String regionGeneration, + String keyRevision) { + String base = build(namespace, digest); + validateConsistencyIdentifier(regionGeneration, "regionGeneration"); + validateConsistencyIdentifier(keyRevision, "keyRevision"); + String key = base + ":g" + regionGeneration + ":r" + keyRevision; + validateMaximumBytes(namespace, key); + return key; + } + + private static void validateConsistencyIdentifier(String value, String field) { + if (value == null + || value.length() < 16 + || value.length() > 64 + || !value.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException(field + " must contain 16..64 Base64URL-safe characters"); + } + } + + private static void validateMaximumBytes(RedisKeyNamespace namespace, String key) { int byteSize = key.getBytes(StandardCharsets.UTF_8).length; if (byteSize > namespace.maximumKeyBytes()) { throw new IllegalArgumentException( @@ -34,6 +65,5 @@ public final class RedisKeyBuilder { + " > " + namespace.maximumKeyBytes()); } - return key; } } diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistry.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistry.java new file mode 100644 index 0000000..a74b528 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistry.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.cache.redis.readiness; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Validated Redis integration-test image references. + * + *

This metadata belongs to build and test tooling. It is not application configuration and must + * not be required by a production runtime. + */ +public final class RedisTestImageRegistry { + + public static final Set REQUIRED_IMAGE_KEYS = + Set.of( + "redis.below-minimum.image", + "redis.minimum.image", + "redis.next-minor.image", + "redis.approved.image", + "toxiproxy.image"); + + private final Map images; + + RedisTestImageRegistry(Map images) { + Set missing = new LinkedHashSet<>(REQUIRED_IMAGE_KEYS); + missing.removeAll(images.keySet()); + Set unknown = new LinkedHashSet<>(images.keySet()); + unknown.removeAll(REQUIRED_IMAGE_KEYS); + if (!missing.isEmpty() || !unknown.isEmpty()) { + throw new IllegalArgumentException( + "Redis test image keys must match the registry; missing=" + + missing + + ", unknown=" + + unknown); + } + this.images = Map.copyOf(new LinkedHashMap<>(images)); + } + + public Map images() { + return images; + } + + public String image(String key) { + String image = images.get(key); + if (image == null) { + throw new IllegalArgumentException("Unknown Redis test image key: " + key); + } + return image; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoader.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoader.java new file mode 100644 index 0000000..a08947c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoader.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.cache.redis.readiness; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Strict loader for pinned Redis test image references. + * + *

This is a build/test utility. The image registry is loaded only from an explicit {@link Path} + * and is never discovered from a production classpath. + */ +public final class RedisTestImageRegistryLoader { + + private static final Pattern DIGEST = Pattern.compile("[0-9a-f]{64}"); + private static final Pattern EXACT_VERSION = Pattern.compile("[0-9][0-9A-Za-z._-]*"); + + private RedisTestImageRegistryLoader() {} + + public static RedisTestImageRegistry load(Path registryFile) throws IOException { + if (registryFile == null) { + throw new IllegalArgumentException("Redis test image registry path must not be null"); + } + List lines = Files.readAllLines(registryFile, StandardCharsets.UTF_8); + Map images = new LinkedHashMap<>(); + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index).trim(); + if (line.isEmpty() || line.startsWith("#") || line.startsWith("!")) { + continue; + } + int separator = line.indexOf('='); + if (separator < 1 || separator == line.length() - 1) { + throw malformed(index + 1, "expected key=value"); + } + String key = line.substring(0, separator).trim(); + String image = line.substring(separator + 1).trim(); + if (images.containsKey(key)) { + throw malformed(index + 1, "duplicate image key: " + key); + } + validateImageReference(key, image); + images.put(key, image); + } + return new RedisTestImageRegistry(images); + } + + private static void validateImageReference(String key, String image) { + String normalized = image.toLowerCase(Locale.ROOT); + if (containsPlaceholder(normalized)) { + throw new IllegalArgumentException( + "Redis test image " + key + " contains a placeholder: " + image); + } + if (image.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException( + "Redis test image " + key + " must not contain whitespace"); + } + String digestMarker = "@sha256:"; + int digestStart = image.indexOf(digestMarker); + if (digestStart < 1 || image.indexOf(digestMarker, digestStart + 1) >= 0) { + throw new IllegalArgumentException( + "Redis test image " + key + " requires exactly one @sha256 digest"); + } + String digest = image.substring(digestStart + digestMarker.length()); + if (!DIGEST.matcher(digest).matches()) { + throw new IllegalArgumentException( + "Redis test image " + key + " has an invalid sha256 digest"); + } + if (digest.chars().allMatch(character -> character == '0')) { + throw new IllegalArgumentException( + "Redis test image " + key + " contains a placeholder digest"); + } + String taggedImage = image.substring(0, digestStart); + int lastSlash = taggedImage.lastIndexOf('/'); + int tagSeparator = taggedImage.lastIndexOf(':'); + if (tagSeparator <= lastSlash || tagSeparator == taggedImage.length() - 1) { + throw new IllegalArgumentException( + "Redis test image " + key + " requires an exact version tag"); + } + String repository = taggedImage.substring(0, tagSeparator); + String tag = taggedImage.substring(tagSeparator + 1); + if (repository.isBlank() || repository.endsWith("/")) { + throw new IllegalArgumentException("Redis test image " + key + " has an invalid repository"); + } + if (tag.equalsIgnoreCase("latest")) { + throw new IllegalArgumentException("Redis test image " + key + " must not use latest"); + } + if (!EXACT_VERSION.matcher(tag).matches()) { + throw new IllegalArgumentException( + "Redis test image " + key + " requires an exact version tag"); + } + } + + private static boolean containsPlaceholder(String value) { + return value.contains("<") + || value.contains(">") + || value.contains("${") + || value.contains("placeholder") + || value.contains("changeme") + || value.contains("todo"); + } + + private static IllegalArgumentException malformed(int lineNumber, String reason) { + return new IllegalArgumentException( + "Malformed Redis test image registry at line " + lineNumber + ": " + reason); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/runtime/RedisClientRuntimeSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/runtime/RedisClientRuntimeSettings.java new file mode 100644 index 0000000..e3c5372 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/runtime/RedisClientRuntimeSettings.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.cache.redis.runtime; + +import java.time.Duration; + +/** Finite deterministic Lettuce queue, timeout, redirect, and topology-refresh settings. */ +public record RedisClientRuntimeSettings( + String clientName, + Duration connectTimeout, + Duration tlsHandshakeTimeout, + Duration acquireTimeout, + Duration commandTimeout, + Duration overallTimeout, + Duration shutdownTimeout, + int maximumQueuedCommands, + int clusterMaximumRedirects, + Duration clusterTopologyRefreshPeriod) { + + private static final Duration MAXIMUM_CONNECT_TIMEOUT = Duration.ofSeconds(30); + private static final Duration MAXIMUM_TLS_HANDSHAKE_TIMEOUT = Duration.ofSeconds(30); + private static final Duration MAXIMUM_ACQUIRE_TIMEOUT = Duration.ofSeconds(30); + private static final Duration MAXIMUM_COMMAND_TIMEOUT = Duration.ofSeconds(30); + private static final Duration MAXIMUM_OVERALL_TIMEOUT = Duration.ofSeconds(60); + private static final Duration MAXIMUM_SHUTDOWN_TIMEOUT = Duration.ofSeconds(30); + private static final Duration MINIMUM_TOPOLOGY_REFRESH = Duration.ofSeconds(1); + private static final Duration MAXIMUM_TOPOLOGY_REFRESH = Duration.ofMinutes(30); + + public RedisClientRuntimeSettings( + String clientName, + Duration commandTimeout, + int maximumQueuedCommands, + int clusterMaximumRedirects, + Duration clusterTopologyRefreshPeriod) { + this( + clientName, + commandTimeout, + commandTimeout, + commandTimeout, + commandTimeout, + commandTimeout.multipliedBy(2), + Duration.ofSeconds(5), + maximumQueuedCommands, + clusterMaximumRedirects, + clusterTopologyRefreshPeriod); + } + + public RedisClientRuntimeSettings( + String clientName, + Duration connectTimeout, + Duration acquireTimeout, + Duration commandTimeout, + Duration overallTimeout, + Duration shutdownTimeout, + int maximumQueuedCommands, + int clusterMaximumRedirects, + Duration clusterTopologyRefreshPeriod) { + this( + clientName, + connectTimeout, + connectTimeout, + acquireTimeout, + commandTimeout, + overallTimeout, + shutdownTimeout, + maximumQueuedCommands, + clusterMaximumRedirects, + clusterTopologyRefreshPeriod); + } + + public RedisClientRuntimeSettings { + if (clientName == null + || !clientName.matches("[a-z][a-z0-9-]{0,62}") + || clientName.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("Redis client name is invalid"); + } + positiveBounded(connectTimeout, MAXIMUM_CONNECT_TIMEOUT, "Redis connect timeout"); + positiveBounded( + tlsHandshakeTimeout, MAXIMUM_TLS_HANDSHAKE_TIMEOUT, "Redis TLS handshake timeout"); + positiveBounded(acquireTimeout, MAXIMUM_ACQUIRE_TIMEOUT, "Redis acquire timeout"); + positiveBounded(commandTimeout, MAXIMUM_COMMAND_TIMEOUT, "Redis command timeout"); + positiveBounded(overallTimeout, MAXIMUM_OVERALL_TIMEOUT, "Redis overall timeout"); + positiveBounded(shutdownTimeout, MAXIMUM_SHUTDOWN_TIMEOUT, "Redis shutdown timeout"); + if (connectTimeout.compareTo(overallTimeout) > 0 + || tlsHandshakeTimeout.compareTo(overallTimeout) > 0 + || acquireTimeout.compareTo(overallTimeout) > 0 + || commandTimeout.compareTo(overallTimeout) > 0) { + throw new IllegalArgumentException( + "Redis connect, TLS handshake, acquire, and command timeouts must not exceed the overall" + + " timeout"); + } + if (maximumQueuedCommands < 1 || maximumQueuedCommands > 4096) { + throw new IllegalArgumentException( + "Redis request queue must contain between 1 and 4096 commands"); + } + if (clusterMaximumRedirects < 1 || clusterMaximumRedirects > 32) { + throw new IllegalArgumentException("Redis Cluster maximum redirects must be in 1..32"); + } + if (clusterTopologyRefreshPeriod == null + || clusterTopologyRefreshPeriod.compareTo(MINIMUM_TOPOLOGY_REFRESH) < 0 + || clusterTopologyRefreshPeriod.compareTo(MAXIMUM_TOPOLOGY_REFRESH) > 0) { + throw new IllegalArgumentException( + "Redis Cluster topology refresh period must be finite and bounded"); + } + } + + private static void positiveBounded(Duration value, Duration maximum, String field) { + if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisCredentialsProvider.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisCredentialsProvider.java new file mode 100644 index 0000000..bbf2f20 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisCredentialsProvider.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import io.lettuce.core.RedisCredentials; +import io.lettuce.core.RedisCredentialsProvider; +import java.util.Arrays; +import java.util.Objects; +import javax.security.auth.Destroyable; +import reactor.core.publisher.Mono; + +/** Runtime-owned Lettuce credential provider whose retained password is wiped on runtime close. */ +public final class DestroyableRedisCredentialsProvider + implements RedisCredentialsProvider, + RedisCredentialsProvider.ImmediateRedisCredentialsProvider, + Destroyable, + AutoCloseable { + + private final String username; + private char[] password; + private boolean destroyed; + + private DestroyableRedisCredentialsProvider(String username, char[] password) { + if (username == null || username.isBlank()) { + throw new IllegalArgumentException("Redis ACL username must be non-empty"); + } + this.username = username; + this.password = password.clone(); + } + + public static DestroyableRedisCredentialsProvider from(String username, char[] password) { + Objects.requireNonNull(password, "Redis password must be non-null"); + if (password.length == 0) { + throw new IllegalArgumentException("Redis password must be non-empty"); + } + return new DestroyableRedisCredentialsProvider(username, password); + } + + @Override + public Mono resolveCredentials() { + return Mono.fromSupplier(this::resolveCredentialsNow); + } + + @Override + public synchronized RedisCredentials resolveCredentialsNow() { + ensureAvailable(); + return new CredentialView(this); + } + + @Override + public synchronized void destroy() { + if (destroyed) { + return; + } + Arrays.fill(password, '\0'); + password = new char[0]; + destroyed = true; + } + + @Override + public synchronized boolean isDestroyed() { + return destroyed; + } + + @Override + public void close() { + destroy(); + } + + @Override + public String toString() { + return "DestroyableRedisCredentialsProvider[username=REDACTED, password=REDACTED]"; + } + + private synchronized String username() { + ensureAvailable(); + return username; + } + + private synchronized char[] password() { + ensureAvailable(); + return password; + } + + private void ensureAvailable() { + if (destroyed) { + throw new IllegalStateException("Redis credentials are destroyed"); + } + } + + private record CredentialView(DestroyableRedisCredentialsProvider owner) + implements RedisCredentials { + + @Override + public String getUsername() { + return owner.username(); + } + + @Override + public boolean hasUsername() { + return true; + } + + @Override + public char[] getPassword() { + return owner.password(); + } + + @Override + public boolean hasPassword() { + return true; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisPem.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisPem.java new file mode 100644 index 0000000..f903e4c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisPem.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; +import javax.security.auth.Destroyable; + +/** Caller-owned PEM bytes that wipe both retained material and every temporary view. */ +public final class DestroyableRedisPem implements Destroyable, AutoCloseable { + + private byte[] value; + private boolean destroyed; + + private DestroyableRedisPem(byte[] value) { + this.value = value.clone(); + } + + public static DestroyableRedisPem from(byte[] value) { + Objects.requireNonNull(value, "Redis PEM material must be non-null"); + return new DestroyableRedisPem(value); + } + + public T use(Function operation) { + Objects.requireNonNull(operation, "operation must be non-null"); + byte[] temporary; + synchronized (this) { + ensureAvailable(); + temporary = value.clone(); + } + try { + return operation.apply(temporary); + } finally { + Arrays.fill(temporary, (byte) 0); + } + } + + @Override + public synchronized void destroy() { + if (destroyed) { + return; + } + Arrays.fill(value, (byte) 0); + value = new byte[0]; + destroyed = true; + } + + @Override + public synchronized boolean isDestroyed() { + return destroyed; + } + + @Override + public void close() { + destroy(); + } + + @Override + public String toString() { + return "DestroyableRedisPem[REDACTED]"; + } + + private void ensureAvailable() { + if (destroyed) { + throw new IllegalStateException("Redis PEM material is destroyed"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisSecret.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisSecret.java new file mode 100644 index 0000000..f78990e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/DestroyableRedisSecret.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; +import javax.security.auth.Destroyable; + +/** Mutable secret storage that wipes its owned bytes and every temporary caller view. */ +public final class DestroyableRedisSecret implements Destroyable, AutoCloseable { + + private static final String REDACTED = "DestroyableRedisSecret[REDACTED]"; + + private char[] value; + private boolean destroyed; + + private DestroyableRedisSecret(char[] value) { + this.value = value.clone(); + } + + public static DestroyableRedisSecret from(char[] value) { + Objects.requireNonNull(value, "Redis secret value must be non-null"); + if (value.length == 0) { + throw new IllegalArgumentException("Redis secret value must be non-empty"); + } + return new DestroyableRedisSecret(value); + } + + public T use(Function operation) { + Objects.requireNonNull(operation, "operation must be non-null"); + char[] temporary; + synchronized (this) { + ensureAvailable(); + temporary = value.clone(); + } + try { + return operation.apply(temporary); + } finally { + Arrays.fill(temporary, '\0'); + } + } + + @Override + public synchronized void destroy() { + if (destroyed) { + return; + } + Arrays.fill(value, '\0'); + value = new char[0]; + destroyed = true; + } + + @Override + public synchronized boolean isDestroyed() { + return destroyed; + } + + @Override + public void close() { + destroy(); + } + + @Override + public String toString() { + return REDACTED; + } + + private void ensureAvailable() { + if (destroyed) { + throw new IllegalStateException("Redis secret material is destroyed"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProvider.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProvider.java new file mode 100644 index 0000000..376587a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProvider.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +/** + * Resolves one explicit Redis secret reference into caller-owned credential material. + * + *

Each successful resolution transfers ownership to the caller, which must close the returned + * value. + */ +@FunctionalInterface +public interface RedisCredentialMaterialProvider { + + VersionedRedisCredentialMaterial resolve(RedisSecretReference reference); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinator.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinator.java new file mode 100644 index 0000000..4cebd4a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinator.java @@ -0,0 +1,248 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +/** + * Serializes credential rotations, probes a fresh runtime, and swaps only validated candidates. + * + *

Failures are intentionally reduced to a bounded status and never retain exception or secret + * text. + */ +final class RedisCredentialRotationCoordinator implements AutoCloseable { + + private static final int MAXIMUM_QUEUE_CAPACITY = 1024; + private static final Duration MAXIMUM_CLOSE_TIMEOUT = Duration.ofSeconds(30); + + @FunctionalInterface + interface CandidateFactory { + + Candidate create(long version); + } + + @FunctionalInterface + interface Probe { + + void verify(RedisRotatableRuntime runtime); + } + + @FunctionalInterface + interface VersionSource { + + long latestVersion(); + } + + record Candidate(long version, Instant expiresAt, RedisRotatableRuntime runtime) { + + public Candidate { + if (version < 1) { + throw new IllegalArgumentException("Redis credential version must be positive"); + } + Objects.requireNonNull(expiresAt, "expiresAt must be non-null"); + Objects.requireNonNull(runtime, "runtime must be non-null"); + } + } + + record Snapshot( + long version, Instant expiresAt, String deploymentId, boolean coordinatorClosed) {} + + record RotationResult(Status status, long activeVersion) {} + + enum Status { + APPLIED, + IGNORED_STALE, + IGNORED_NOT_EXPIRED, + FAILED, + REJECTED_OVERLOADED, + CLOSED + } + + private final CandidateFactory candidateFactory; + private final Probe probe; + private final VersionSource versionSource; + private final Clock clock; + private final Duration closeTimeout; + private final ThreadPoolExecutor executor; + private final AtomicReference active; + private final AtomicBoolean closed = new AtomicBoolean(); + + RedisCredentialRotationCoordinator( + Candidate initial, + CandidateFactory candidateFactory, + Probe probe, + VersionSource versionSource, + Clock clock, + int queueCapacity, + Duration closeTimeout) { + this.active = + new AtomicReference<>(Objects.requireNonNull(initial, "initial must be non-null")); + this.candidateFactory = + Objects.requireNonNull(candidateFactory, "candidateFactory must be non-null"); + this.probe = Objects.requireNonNull(probe, "probe must be non-null"); + this.versionSource = Objects.requireNonNull(versionSource, "versionSource must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + if (queueCapacity < 1 || queueCapacity > MAXIMUM_QUEUE_CAPACITY) { + throw new IllegalArgumentException("Redis rotation queue capacity must be in 1..1024"); + } + if (closeTimeout == null + || closeTimeout.isZero() + || closeTimeout.isNegative() + || closeTimeout.compareTo(MAXIMUM_CLOSE_TIMEOUT) > 0) { + throw new IllegalArgumentException( + "Redis rotation close timeout must be positive and bounded"); + } + this.closeTimeout = closeTimeout; + this.executor = + new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(queueCapacity), + runnable -> { + Thread thread = new Thread(runnable, "redis-credential-rotation"); + thread.setDaemon(true); + return thread; + }, + new ThreadPoolExecutor.AbortPolicy()); + } + + public CompletableFuture rotate(long version) { + if (version < 1) { + throw new IllegalArgumentException("Redis credential version must be positive"); + } + return submit(() -> rotateInsideExecutor(version)); + } + + public CompletableFuture refreshIfExpired(Instant now) { + Objects.requireNonNull(now, "now must be non-null"); + return submit( + () -> { + Candidate current = active.get(); + if (current.expiresAt().isAfter(now)) { + return result(Status.IGNORED_NOT_EXPIRED); + } + long latestVersion; + try { + latestVersion = versionSource.latestVersion(); + } catch (RuntimeException ignored) { + return result(Status.FAILED); + } + if (latestVersion < 1) { + return result(Status.FAILED); + } + return rotateInsideExecutor(latestVersion); + }); + } + + public Snapshot snapshot() { + Candidate current = active.get(); + return new Snapshot( + current.version(), current.expiresAt(), current.runtime().deploymentId(), closed.get()); + } + + private RotationResult rotateInsideExecutor(long requestedVersion) { + Candidate current = active.get(); + if (requestedVersion <= current.version()) { + return result(Status.IGNORED_STALE); + } + + Candidate replacement = null; + try { + replacement = + Objects.requireNonNull( + candidateFactory.create(requestedVersion), + "Redis rotation candidate must be non-null"); + if (replacement.version() != requestedVersion + || replacement.runtime() == current.runtime() + || !replacement.expiresAt().isAfter(clock.instant())) { + closeCandidateUnlessActive(replacement); + return result(Status.FAILED); + } + probe.verify(replacement.runtime()); + if (closed.get()) { + closeCandidateUnlessActive(replacement); + return result(Status.CLOSED); + } + + Candidate previous = active.getAndSet(replacement); + closeQuietly(previous.runtime()); + return new RotationResult(Status.APPLIED, replacement.version()); + } catch (RuntimeException ignored) { + closeCandidateUnlessActive(replacement); + return result(Status.FAILED); + } + } + + private CompletableFuture submit(Supplier operation) { + if (closed.get()) { + return CompletableFuture.completedFuture(result(Status.CLOSED)); + } + CompletableFuture result = new CompletableFuture<>(); + try { + executor.execute( + () -> { + if (closed.get()) { + result.complete(result(Status.CLOSED)); + return; + } + try { + result.complete(operation.get()); + } catch (RuntimeException ignored) { + result.complete(result(Status.FAILED)); + } + }); + return result; + } catch (RejectedExecutionException ignored) { + Status status = closed.get() ? Status.CLOSED : Status.REJECTED_OVERLOADED; + return CompletableFuture.completedFuture(result(status)); + } + } + + private RotationResult result(Status status) { + return new RotationResult(status, active.get().version()); + } + + private void closeCandidateUnlessActive(Candidate candidate) { + if (candidate != null && candidate.runtime() != active.get().runtime()) { + closeQuietly(candidate.runtime()); + } + } + + private static void closeQuietly(RedisRotatableRuntime runtime) { + try { + runtime.close(); + } catch (RuntimeException ignored) { + // Runtime close failures must not roll back a successful atomic swap. + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + executor.shutdown(); + try { + if (!executor.awaitTermination(closeTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + executor.shutdownNow(); + executor.awaitTermination(closeTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + } catch (InterruptedException exception) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } finally { + closeQuietly(active.get().runtime()); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisRotatableRuntime.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisRotatableRuntime.java new file mode 100644 index 0000000..5824f5d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisRotatableRuntime.java @@ -0,0 +1,14 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +/** + * Lifecycle-only view used by credential rotation. + * + *

The boundary intentionally exposes no Redis command or native client API. + */ +public interface RedisRotatableRuntime extends AutoCloseable { + + String deploymentId(); + + @Override + void close(); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSecretReference.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSecretReference.java new file mode 100644 index 0000000..e8430cb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSecretReference.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import java.util.Objects; + +/** Validated reference to externally managed Redis secret material. */ +public final class RedisSecretReference { + + private static final int MAXIMUM_REFERENCE_LENGTH = 512; + private static final String REDACTED = "RedisSecretReference[REDACTED]"; + + private final String value; + + private RedisSecretReference(String value) { + this.value = value; + } + + public static RedisSecretReference parse(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Redis secret reference must be non-empty"); + } + String normalized = value.trim(); + if (!normalized.startsWith("secret://") + || normalized.length() <= "secret://".length() + || normalized.length() > MAXIMUM_REFERENCE_LENGTH + || normalized.chars().anyMatch(Character::isWhitespace) + || normalized.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException( + "Redis secret reference must be a bounded secret:// reference"); + } + return new RedisSecretReference(normalized); + } + + /** + * Returns the reference only to a material provider implementation. + * + *

Callers must never include the returned value in logs, metrics, exceptions, or diagnostics. + */ + public String valueForResolution() { + return value; + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof RedisSecretReference reference && value.equals(reference.value)); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return REDACTED; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSslOptionsFactory.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSslOptionsFactory.java new file mode 100644 index 0000000..d94edff --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisSslOptionsFactory.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import io.lettuce.core.SslOptions; +import java.io.ByteArrayInputStream; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.time.Clock; +import java.time.Duration; +import java.util.Collection; +import java.util.Objects; +import javax.net.ssl.TrustManagerFactory; + +/** Builds a fail-closed SSL context from explicit versioned PEM trust material only. */ +public final class RedisSslOptionsFactory { + + private static final Duration MAXIMUM_HANDSHAKE_TIMEOUT = Duration.ofSeconds(30); + + private final RedisTrustMaterialProvider materialProvider; + private final Clock clock; + + public RedisSslOptionsFactory(RedisTrustMaterialProvider materialProvider, Clock clock) { + this.materialProvider = + Objects.requireNonNull(materialProvider, "materialProvider must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + public SslOptions create(RedisDeploymentSettings.Tls tls, Duration handshakeTimeout) { + Objects.requireNonNull(tls, "tls must be non-null"); + if (!tls.enabled() || !tls.verifyHostname()) { + throw new IllegalArgumentException( + "Redis TLS must be enabled with full hostname verification"); + } + if (handshakeTimeout == null + || handshakeTimeout.isZero() + || handshakeTimeout.isNegative() + || handshakeTimeout.compareTo(MAXIMUM_HANDSHAKE_TIMEOUT) > 0) { + throw new IllegalArgumentException( + "Redis TLS handshake timeout must be positive and bounded"); + } + + RedisSecretReference reference = RedisSecretReference.parse(tls.trustBundleReference()); + try (VersionedRedisTrustMaterial material = resolve(reference)) { + if (material == null) { + throw new IllegalStateException("Redis trust material resolution returned no value"); + } + if (material.isExpiredAt(clock.instant())) { + throw new IllegalStateException("Redis trust material is expired"); + } + TrustManagerFactory trustManager = material.usePem(RedisSslOptionsFactory::trustManager); + return SslOptions.builder() + .jdkSslProvider() + .trustManager(trustManager) + .handshakeTimeout(handshakeTimeout) + .protocols("TLSv1.3", "TLSv1.2") + .build(); + } + } + + private VersionedRedisTrustMaterial resolve(RedisSecretReference reference) { + try { + return materialProvider.resolve(reference); + } catch (RuntimeException ignored) { + throw new IllegalStateException("Redis trust material resolution failed"); + } + } + + private static TrustManagerFactory trustManager(byte[] pem) { + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Collection certificates = + certificateFactory.generateCertificates(new ByteArrayInputStream(pem)); + if (certificates.isEmpty()) { + throw new IllegalArgumentException("Redis trust PEM must contain an X.509 certificate"); + } + KeyStore trustStore = KeyStore.getInstance("PKCS12"); + trustStore.load(null, null); + int index = 0; + for (Certificate certificate : certificates) { + trustStore.setCertificateEntry("redis-ca-" + index++, certificate); + } + TrustManagerFactory trustManager = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManager.init(trustStore); + return trustManager; + } catch (CertificateException exception) { + throw new IllegalArgumentException("Redis trust PEM is not a valid X.509 bundle"); + } catch (GeneralSecurityException | java.io.IOException exception) { + throw new IllegalStateException("Redis explicit trust manager could not be initialized"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProvider.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProvider.java new file mode 100644 index 0000000..29a77be --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProvider.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +/** Resolves versioned PEM trust material without exposing it through configuration values. */ +@FunctionalInterface +public interface RedisTrustMaterialProvider { + + VersionedRedisTrustMaterial resolve(RedisSecretReference reference); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisCredentialMaterial.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisCredentialMaterial.java new file mode 100644 index 0000000..f1cb2f7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisCredentialMaterial.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import java.time.Instant; +import java.util.Objects; +import java.util.function.Function; + +/** Versioned and expiring caller-owned Redis password material. */ +public final class VersionedRedisCredentialMaterial implements AutoCloseable { + + private static final int MAXIMUM_VERSION_LENGTH = 128; + + private final String version; + private final Instant expiresAt; + private final DestroyableRedisSecret secret; + + public VersionedRedisCredentialMaterial( + String version, Instant expiresAt, DestroyableRedisSecret secret) { + if (version == null + || version.isBlank() + || version.length() > MAXIMUM_VERSION_LENGTH + || version.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("Redis credential version is invalid"); + } + this.version = version.trim(); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt must be non-null"); + this.secret = Objects.requireNonNull(secret, "secret must be non-null"); + } + + public String version() { + return version; + } + + public Instant expiresAt() { + return expiresAt; + } + + public T useSecret(Function operation) { + return secret.use(operation); + } + + public boolean isDestroyed() { + return secret.isDestroyed(); + } + + public boolean isExpiredAt(Instant instant) { + Objects.requireNonNull(instant, "instant must be non-null"); + return !expiresAt.isAfter(instant); + } + + @Override + public void close() { + secret.destroy(); + } + + @Override + public String toString() { + return "VersionedRedisCredentialMaterial[version=" + + version + + ", expiresAt=" + + expiresAt + + ", secret=REDACTED]"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisTrustMaterial.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisTrustMaterial.java new file mode 100644 index 0000000..95940ad --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/security/VersionedRedisTrustMaterial.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import java.time.Instant; +import java.util.Objects; +import java.util.function.Function; + +/** Versioned, expiring, caller-owned PEM trust material. */ +public final class VersionedRedisTrustMaterial implements AutoCloseable { + + private static final int MAXIMUM_VERSION_LENGTH = 128; + + private final String version; + private final Instant expiresAt; + private final DestroyableRedisPem pem; + + public VersionedRedisTrustMaterial(String version, Instant expiresAt, DestroyableRedisPem pem) { + if (version == null + || version.isBlank() + || version.length() > MAXIMUM_VERSION_LENGTH + || version.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("Redis trust material version is invalid"); + } + this.version = version.trim(); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt must be non-null"); + this.pem = Objects.requireNonNull(pem, "pem must be non-null"); + } + + public String version() { + return version; + } + + public Instant expiresAt() { + return expiresAt; + } + + public T usePem(Function operation) { + return pem.use(operation); + } + + public boolean isDestroyed() { + return pem.isDestroyed(); + } + + public boolean isExpiredAt(Instant instant) { + Objects.requireNonNull(instant, "instant must be non-null"); + return !expiresAt.isAfter(instant); + } + + @Override + public void close() { + pem.destroy(); + } + + @Override + public String toString() { + return "VersionedRedisTrustMaterial[version=" + + version + + ", expiresAt=" + + expiresAt + + ", pem=REDACTED]"; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/idempotency-program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/idempotency-program-set.json new file mode 100644 index 0000000..ab2479f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/idempotency-program-set.json @@ -0,0 +1,201 @@ +{ + "schemaVersion": 1, + "programSet": "ca-redis-request-replay", + "semanticRevision": 1, + "applicationContractVersion": 2, + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 1, + "readiness": "CANDIDATE", + "exactlyOnceScope": "NONE", + "programs": [ + { + "id": "idempotency-claim-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_claim_v1", + "scriptResource": "redis/scripts/idempotency-claim-v1.lua", + "sha256": "75689d410e7d70c0e86efb660b0361bd3176097da30ae0deff43081c9d7b10cc", + "keyCount": 1, + "argumentCount": 8, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "fingerprint", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 5, "name": "processingTtlMillis", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 6, "name": "recordTtlMillis", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 7, "name": "responseCodecId", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 8, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["ACQUIRED", "REPLAYED_ACQUIRE", "TAKEN_OVER_CLAIMED", "COMPLETED_REPLAY", "IN_PROGRESS", "RECOVERY_REQUIRED", "FINGERPRINT_MISMATCH", "OWNER_OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "HDEL", "PEXPIRE"] + }, + { + "id": "idempotency-start-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_start_v1", + "scriptResource": "redis/scripts/idempotency-start-v1.lua", + "sha256": "1cad8924b6293ed30cb21b2fdae4ba392abe0a22498686961c6243f3efb2205b", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "attempt", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["STARTED", "ALREADY_STARTED_SAME_OPERATION", "ABSENT", "NOT_OWNER", "NOT_CLAIMED", "OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET"] + }, + { + "id": "idempotency-renew-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_renew_v1", + "scriptResource": "redis/scripts/idempotency-renew-v1.lua", + "sha256": "0b44a85b0bd304c9bc01d9043910285ceac66ab40d0ae5db85d36b34f76ce1d7", + "keyCount": 1, + "argumentCount": 5, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "attempt", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "processingTtlMillis", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 5, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["RENEWED", "ALREADY_RENEWED_SAME_OPERATION", "ABSENT", "NOT_OWNER", "NOT_IN_PROGRESS", "OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "PEXPIRE"] + }, + { + "id": "idempotency-complete-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_complete_v1", + "scriptResource": "redis/scripts/idempotency-complete-v1.lua", + "sha256": "db82663e53fe0907e8a1414632216b73d443399bad98083d093015d3111d294d", + "keyCount": 1, + "argumentCount": 7, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "attempt", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "responsePayload", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 5, "name": "responseDigest", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 6, "name": "replayTtlMillis", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 7, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["COMPLETED", "ALREADY_COMPLETED_SAME_RESULT", "RESPONSE_CONFLICT", "ABSENT", "NOT_OWNER", "NOT_IN_PROGRESS", "OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "HDEL", "PEXPIRE"] + }, + { + "id": "idempotency-fail-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_fail_v1", + "scriptResource": "redis/scripts/idempotency-fail-v1.lua", + "sha256": "e935ab74c4997bdcaf6a58c490fe772b554ec38e84e0479c810a906fef6434b1", + "keyCount": 1, + "argumentCount": 6, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "attempt", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "failureDisposition", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 5, "name": "retentionMillis", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 6, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["MARKED_RETRYABLE", "MARKED_ABANDONED", "ALREADY_MARKED_SAME_OPERATION", "ABSENT", "NOT_OWNER", "NOT_IN_PROGRESS", "OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "HDEL", "PEXPIRE"] + }, + { + "id": "idempotency-release-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_release_v1", + "scriptResource": "redis/scripts/idempotency-release-v1.lua", + "sha256": "89c8225ce52614c6b57bbc2b37e148029cda9d6c2b0bcdf2830a13d258c223d8", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "attempt", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["RELEASED_BEFORE_EXECUTION", "ALREADY_RELEASED_SAME_OPERATION", "ABSENT", "NOT_OWNER", "EXECUTION_ALREADY_STARTED", "OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "HDEL", "PEXPIRE"] + }, + { + "id": "idempotency-inspect-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_idempotency_v1", + "registeredFunctionName": "ca_idempotency_inspect_v1", + "scriptResource": "redis/scripts/idempotency-inspect-v1.lua", + "sha256": "d7a7242e92c873ad3e7fbde2569703c1bf6a9240d4ac0faa768b70bb879ef56e", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "recordKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 2, "name": "fingerprint", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 3, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 12000}, {"index": 4, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 12000}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 12000, "orderedFields": ["status", "attempt", "expiresAtMillis", "responsePayload", "responseDigest", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 12000, "maximumEntries": 32}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "operation-token", "numeric-and-ttl-bounds"], + "statuses": ["ABSENT", "CLAIMED_SAME_OPERATION", "EXECUTING_SAME_OPERATION", "COMPLETED_REPLAY", "IN_PROGRESS_OTHER", "FAILED_RETRYABLE", "ABANDONED", "FINGERPRINT_MISMATCH", "OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=32", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET"] + } + ] +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/lease-program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/lease-program-set.json new file mode 100644 index 0000000..373e70f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/lease-program-set.json @@ -0,0 +1,122 @@ +{ + "schemaVersion": 1, + "programSet": "ca-redis-efficiency-lease", + "semanticRevision": 1, + "applicationContractVersion": 2, + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 1, + "readiness": "CANDIDATE", + "guarantee": "EFFICIENCY_ONLY", + "fencing": false, + "role": "COORDINATION", + "programs": [ + { + "id": "lease-acquire-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_efficiency_lease_v1", + "registeredFunctionName": "ca_lease_acquire_v1", + "scriptResource": "redis/scripts/lease-acquire-v1.lua", + "sha256": "3c0e075f52777d00da0d1be1af554453dbe2c520b47a483eb2bfd9923f42cabd", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "leaseKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 128, "orderedFields": ["status", "remainingMillis", "serverNowMillis", "expiresAtMillis", "stateRevision", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 1024, "maximumEntries": 5}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 86400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "owner-and-operation-token", "ttl-bound-when-present"], + "statuses": ["ACQUIRED", "REPLAYED_SAME_OPERATION", "CONTENDED", "OWNER_OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=5", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HSET", "PEXPIRE", "HMGET", "PTTL", "DEL"] + }, + { + "id": "lease-inspect-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_efficiency_lease_v1", + "registeredFunctionName": "ca_lease_inspect_v1", + "scriptResource": "redis/scripts/lease-inspect-v1.lua", + "sha256": "a0ea3c1a20dfd4e2961c4a23cd5d6b7694e49940540aee10ca965c3119d5997a", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "leaseKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 128, "orderedFields": ["status", "remainingMillis", "serverNowMillis", "expiresAtMillis", "stateRevision", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 1024, "maximumEntries": 5}, + "ttl": {"mode": "READ_ONLY", "minimumMillis": 0, "maximumMillis": 86400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "owner-and-operation-token", "ttl-bound-when-present"], + "statuses": ["OWNED", "ABSENT", "NOT_OWNER", "OWNER_OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=5", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "READ_ONLY_RETRY_SAFE", + "timeoutCertainty": "READ_ONLY", + "aclCommands": ["TYPE", "TIME", "HMGET", "PTTL"] + }, + { + "id": "lease-renew-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_efficiency_lease_v1", + "registeredFunctionName": "ca_lease_renew_v1", + "scriptResource": "redis/scripts/lease-renew-v1.lua", + "sha256": "340c688e93d81558aef4e90af9a998297f324a4f0c6c75344fa59df9749bd8d7", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "leaseKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 128, "orderedFields": ["status", "remainingMillis", "serverNowMillis", "expiresAtMillis", "stateRevision", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 1024, "maximumEntries": 5}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 86400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "owner-and-operation-token", "ttl-bound-when-present"], + "statuses": ["RENEWED", "ABSENT", "NOT_OWNER", "OWNER_OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=5", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "PTTL", "HSET", "PEXPIRE"] + }, + { + "id": "lease-release-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_efficiency_lease_v1", + "registeredFunctionName": "ca_lease_release_v1", + "scriptResource": "redis/scripts/lease-release-v1.lua", + "sha256": "24fcf0600ebfa57c1bf9b01b186ee35cf5fc9cd23ba06073e17d647b412ba85f", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 6, + "keys": [{"index": 1, "name": "leaseKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "ownerToken", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 6, "maximumFieldBytes": 128, "orderedFields": ["status", "remainingMillis", "serverNowMillis", "expiresAtMillis", "stateRevision", "operationId"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 1024, "maximumEntries": 5}, + "ttl": {"mode": "DELETE_OR_PRESERVE", "minimumMillis": 0, "maximumMillis": 86400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "stored-schema", "owner-and-operation-token", "ttl-bound-when-present"], + "statuses": ["RELEASED", "ALREADY_ABSENT", "NOT_OWNER", "OWNER_OPERATION_CONFLICT", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=5", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_BY_OPERATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "PTTL", "DEL"] + } + ] +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/primitive-program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/primitive-program-set.json new file mode 100644 index 0000000..7732882 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/primitive-program-set.json @@ -0,0 +1,362 @@ +{ + "schemaVersion": 1, + "programSet": "ca-redis-primitive-atomic-v1", + "semanticRevision": 1, + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 1, + "readiness": "INTERNAL_CANDIDATE", + "guarantee": "bounded internal helpers; no public capability or R2 promotion", + "programs": [ + { + "id": "increment-with-initial-ttl-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_increment_with_initial_ttl_v1", + "scriptResource": "redis/scripts/increment-with-initial-ttl-v1.lua", + "sha256": "0fb9bff819fc4ca3e5214b162ba75241ede9693c264dc2d8e7c5e0beda7d9229", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "counterKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "delta", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "minimum", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "maximum", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 4, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "value"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string-counter", "maximumBytes": 32, "maximumEntries": 1}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["UPDATED", "LIMIT_EXCEEDED", "OVERFLOW", "MALFORMED_VALUE", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "GET", "PTTL", "SET", "INCRBY"] + }, + { + "id": "compare-and-set-with-ttl-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_compare_and_set_with_ttl_v1", + "scriptResource": "redis/scripts/compare-and-set-with-ttl-v1.lua", + "sha256": "11a6efb5ed01fab40dc5154c362c11a804f567bba6a18474a9456c6d72665f08", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "valueKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "expectedKind", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "expectedValue", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "newValue", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 4, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 1048576, "maximumEntries": 1}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["UPDATED", "MISMATCH", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "GET", "SET"] + }, + { + "id": "bounded-set-admission-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_set_admission_v1", + "scriptResource": "redis/scripts/bounded-set-admission-v1.lua", + "sha256": "d3f69f14020a43ecc2db79d9c7077c63800367641816fdc979ce3f7d883acfb3", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "setKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "member", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "capacity", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "cardinality"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "set", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["ADMITTED", "ALREADY_PRESENT", "CAPACITY_EXCEEDED", "TTL_APPLY_FAILED", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "bounded-cardinality<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "SISMEMBER", "SCARD", "SADD", "PEXPIRE", "DEL"] + }, + { + "id": "bounded-list-admission-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_list_admission_v1", + "scriptResource": "redis/scripts/bounded-list-admission-v1.lua", + "sha256": "be2e1435f128175c03715a79fa6775c0e4356997ee9fee7ff281f54907c77048", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "listKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "value", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "capacity", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "cardinality"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "list", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["ADMITTED", "CAPACITY_EXCEEDED", "TTL_APPLY_FAILED", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "bounded-cardinality<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "LLEN", "RPUSH", "PEXPIRE", "DEL"] + }, + { + "id": "hash-revision-cas-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_hash_revision_cas_v1", + "scriptResource": "redis/scripts/hash-revision-cas-v1.lua", + "sha256": "70134ac835a7a9e01d30b7479ed59a197d70826b204ec81b114882cd184c5568", + "keyCount": 1, + "argumentCount": 5, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "hashKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "expectedKind", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "expectedRevision", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "newRevision", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 4, "name": "value", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 5, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "revision"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 1048576, "maximumEntries": 2}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["UPDATED", "MISMATCH", "MALFORMED_REVISION", "TTL_APPLY_FAILED", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields=2", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "HLEN", "HEXISTS", "HGET", "HSET", "PEXPIRE", "DEL"] + }, + { + "id": "bounded-hash-field-admission-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_hash_field_admission_v1", + "scriptResource": "redis/scripts/bounded-hash-field-admission-v1.lua", + "sha256": "d7012d23034475c68a7a5c7ebb5507831cbe766e6fcf68c4f23073747fa2f687", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "hashKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "field", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "value", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "capacity", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 4, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["ADMITTED", "SET_EXISTING", "CAPACITY_EXCEEDED", "STATE_OVER_CAPACITY", "TTL_APPLY_FAILED", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "bounded-hash-fields<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "HLEN", "HEXISTS", "HSET", "PEXPIRE", "DEL"] + }, + { + "id": "bounded-zset-admission-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_zset_admission_v1", + "scriptResource": "redis/scripts/bounded-zset-admission-v1.lua", + "sha256": "c20a4a0c248f33d0c4558ccc1309b1b2b636f92c9007c0c1bc047cbc59a859bf", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "zsetKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "member", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "score", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "capacity", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 4, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "zset", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["ADDED", "SCORE_CHANGED", "UNCHANGED", "CAPACITY_EXCEEDED", "STATE_OVER_CAPACITY", "TTL_APPLY_FAILED", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "bounded-zset-cardinality<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "ZCARD", "ZSCORE", "ZADD", "PEXPIRE", "DEL"] + }, + { + "id": "zset-bounded-trim-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_zset_bounded_trim_v1", + "scriptResource": "redis/scripts/zset-bounded-trim-v1.lua", + "sha256": "1fe53dd3249da264c347231be01f201784023177aaa386af8a74dc35b57a9141", + "keyCount": 1, + "argumentCount": 2, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "zsetKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "inclusiveCutoffScore", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "maximumRemovals", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "zset", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["TRIMMED", "TOO_EXPENSIVE", "CORRUPT_AFTER_WRITE", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(N), removals<=1024", + "maximumIterations": 1024, + "stateGrowth": "bounded-zset-cardinality<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "ZCOUNT", "ZREMRANGEBYSCORE"] + }, + { + "id": "guarded-list-trim-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_guarded_list_trim_v1", + "scriptResource": "redis/scripts/guarded-list-trim-v1.lua", + "sha256": "231a4320093ac0473d067a0a39981606352f6cd31521b305231798e9d918b1f9", + "keyCount": 1, + "argumentCount": 2, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "listKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "retainCount", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "maximumRemovals", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "list", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["TRIMMED", "TOO_EXPENSIVE", "CORRUPT_AFTER_WRITE", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(N), removals<=1024", + "maximumIterations": 1024, + "stateGrowth": "bounded-list-cardinality<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "LLEN", "LTRIM"] + }, + { + "id": "bounded-geo-admission-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_geo_admission_v1", + "scriptResource": "redis/scripts/bounded-geo-admission-v1.lua", + "sha256": "45985a07febd880931823662c62925d5b32f707c4c553c303bed315b5ec4020d", + "keyCount": 1, + "argumentCount": 5, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "geoKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "member", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "longitude", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "latitude", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 4, "name": "capacity", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 5, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 128, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "geo-zset", "maximumBytes": 1048576, "maximumEntries": 1024}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["ADDED", "POSITION_CHANGED", "UNCHANGED", "CAPACITY_EXCEEDED", "STATE_OVER_CAPACITY", "TTL_APPLY_FAILED", "MISSING_TTL", "WRONG_TYPE", "INVALID"], + "complexity": "O(1), capacity<=1024", + "maximumIterations": 0, + "stateGrowth": "bounded-geo-cardinality<=1024", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_INSPECT_AFTER_RESPONSE_LOSS", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "ZCARD", "ZSCORE", "GEOADD", "PEXPIRE", "DEL"] + }, + { + "id": "bounded-mget-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_mget_v1", + "scriptResource": "redis/scripts/bounded-mget-v1.lua", + "sha256": "e232b0b19a3969601db955f2831b249b4567df765cb50bc400f4a47554c0e697", + "keyCount": 4, + "argumentCount": 3, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "valueKey1", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "valueKey2", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 3, "name": "valueKey3", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 4, "name": "valueKey4", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "requestedKeyCount", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "maximumResultBytes", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "maximumValueBytes", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 2097152, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "four-bounded-strings", "maximumBytes": 2097152, "maximumEntries": 4}, + "ttl": {"mode": "READ_ONLY", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["OK", "TOO_LARGE", "VALUE_TOO_LARGE", "WRONG_TYPE", "INVALID"], + "complexity": "O(N), N<=1024 and encoded bytes<=2097152", + "maximumIterations": 4, + "stateGrowth": "read-only-no-growth", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "READ_ONLY_RETRY_SAFE", + "timeoutCertainty": "READ_ONLY", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "STRLEN", "GET"] + }, + { + "id": "bounded-hash-scan-page-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_hash_scan_page_v1", + "scriptResource": "redis/scripts/bounded-hash-scan-page-v1.lua", + "sha256": "3bc78d5816c0e91b897ea911743fde4f52688c852e85af50d513f247cac63e2c", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "hashKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "cursor", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "corruptionCeiling", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "maximumResultBytes", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 2097152, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 2097152, "maximumEntries": 1024}, + "ttl": {"mode": "READ_ONLY", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["PAGE", "TOO_LARGE", "STATE_OVER_CAPACITY", "WRONG_TYPE", "INVALID"], + "complexity": "O(N), N<=1024 and encoded bytes<=2097152", + "maximumIterations": 1024, + "stateGrowth": "read-only-no-growth", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "READ_ONLY_RETRY_SAFE", + "timeoutCertainty": "READ_ONLY", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "HLEN", "HSCAN"] + }, + { + "id": "bounded-set-scan-page-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_atomic_v1", + "registeredFunctionName": "ca_bounded_set_scan_page_v1", + "scriptResource": "redis/scripts/bounded-set-scan-page-v1.lua", + "sha256": "9b9a25f98c6b0c3d13f579d199c61c2c719dacfd5ddd363b8267c7b6cb99c03c", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 3, + "keys": [{"index": 1, "name": "setKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "cursor", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 2, "name": "corruptionCeiling", "type": "opaque-bytes", "maximumBytes": 1048576}, {"index": 3, "name": "maximumResultBytes", "type": "opaque-bytes", "maximumBytes": 1048576}], + "resultSchema": {"version": 1, "fieldCount": 3, "maximumFieldBytes": 2097152, "orderedFields": ["version", "status", "detail"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "set", "maximumBytes": 2097152, "maximumEntries": 1024}, + "ttl": {"mode": "READ_ONLY", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "canonical-numeric-and-byte-bounds", "existing-ttl-bound", "acl-preflight-for-every-post-validation-command"], + "statuses": ["PAGE", "TOO_LARGE", "STATE_OVER_CAPACITY", "WRONG_TYPE", "INVALID"], + "complexity": "O(N), N<=1024 and encoded bytes<=2097152", + "maximumIterations": 1024, + "stateGrowth": "read-only-no-growth", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "READ_ONLY_RETRY_SAFE", + "timeoutCertainty": "READ_ONLY", + "aclCommands": ["EVALSHA", "SCRIPT|LOAD", "TYPE", "SCARD", "SSCAN"] + } + ] +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json index ad74ddb..b073075 100644 --- a/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/program-set.json @@ -1,38 +1,233 @@ { + "schemaVersion": 1, "programSet": "ca-redis-programs-v1-foundation", + "semanticRevision": 1, "minimumRedisVersion": "7.2", "resultSchemaVersion": 1, "readiness": "R0", + "semanticProviders": { + "cacheRefresh": { + "claimProgram": "cache-refresh-claim-v1", + "releaseProgram": "compare-and-delete-v1", + "guarantee": "bounded duplicate-refresh suppression with a lease TTL of at most 5 minutes; not a business correctness lock; same owner and operation replay inspects ownership with no TTL renewal; release deletes only the exact owner and operation state" + } + }, "programs": [ + { + "id": "bounded-get-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_v1", + "registeredFunctionName": "ca_bounded_get_v1", + "scriptResource": "redis/scripts/bounded-get-v1.lua", + "sha256": "d2011a5604b3352f06674901a0ca8508e16344f070bc64a1f4fb73be310cbda5", + "keyCount": 1, + "argumentCount": 1, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "entryKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "maximumReadableBytes", "type": "opaque-bytes", "maximumBytes": 16}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 16777216, "orderedFields": ["value"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 16777216, "maximumEntries": 1}, + "ttl": {"mode": "READ_ONLY", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "maximum-readable-value-bound"], + "statuses": ["VALUE", "ABSENT", "VALUE_TOO_LARGE"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "READ_ONLY_RETRY_SAFE", + "timeoutCertainty": "READ_ONLY", + "aclCommands": ["GETRANGE", "EXISTS"] + }, { "id": "compare-and-delete-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_v1", + "registeredFunctionName": "ca_compare_and_delete_v1", "scriptResource": "redis/scripts/compare-and-delete-v1.lua", "sha256": "d0fa9beaa37353ec96be36e3158e06b33165b15489c67e9ca8e4800dac09b25a", "keyCount": 1, "argumentCount": 1, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "ownerKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "expectedOwner", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 128, "maximumEntries": 1}, + "ttl": {"mode": "DELETE_OR_PRESERVE", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], "statuses": ["DELETED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"], "complexity": "O(1)", - "timeoutCertainty": "INDETERMINATE" + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "REPEAT_DESIRED_ABSENT", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "GET", "DEL"] }, { "id": "compare-and-expire-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_v1", + "registeredFunctionName": "ca_compare_and_expire_v1", "scriptResource": "redis/scripts/compare-and-expire-v1.lua", "sha256": "5665fe349f2800c061ff3c86ec33ff11cb6706ee35c605b8e68db21cd08bd7e0", "keyCount": 1, "argumentCount": 2, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "ownerKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "expectedOwner", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 128, "maximumEntries": 1}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], "statuses": ["RENEWED", "ABSENT", "NOT_OWNER", "WRONG_TYPE", "INVALID"], "complexity": "O(1)", - "timeoutCertainty": "INDETERMINATE" + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_OWNER_BEFORE_REPEAT", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "GET", "PEXPIRE"] }, { "id": "set-if-absent-with-ttl-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_primitive_v1", + "registeredFunctionName": "ca_set_if_absent_with_ttl_v1", "scriptResource": "redis/scripts/set-if-absent-with-ttl-v1.lua", "sha256": "777014f7a23435b5701e2d0d286aef60a2f5d54a7836e94122527b28098dc010", "keyCount": 1, "argumentCount": 3, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "entryKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "value", "type": "opaque-bytes", "maximumBytes": 16778272}, {"index": 2, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 16778272}, {"index": 3, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 16778272}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 16777216, "maximumEntries": 1}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], "statuses": ["SET", "EXISTS", "WRONG_TYPE", "INVALID"], "complexity": "O(1)", - "timeoutCertainty": "INDETERMINATE" + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "OPERATION_TOKEN_REPLAYABLE", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "SET"] + }, + { + "id": "replace-if-observed-with-ttl-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_cache_v1", + "registeredFunctionName": "ca_replace_if_observed_with_ttl_v1", + "scriptResource": "redis/scripts/replace-if-observed-with-ttl-v1.lua", + "sha256": "8814de08fdf073ba373522cfd40ec321c2343f90d95ee1aa3804fe295fbace65", + "keyCount": 1, + "argumentCount": 4, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "entryKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "observedDigest", "type": "opaque-bytes", "maximumBytes": 16778272}, {"index": 2, "name": "newEnvelope", "type": "opaque-bytes", "maximumBytes": 16778272}, {"index": 3, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 16778272}, {"index": 4, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 16778272}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 16777216, "maximumEntries": 1}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], + "statuses": ["REPLACED", "ABSENT", "NOT_MATCHED", "WRONG_TYPE", "INVALID"], + "complexity": "O(N), N<=32", + "maximumIterations": 32, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "INSPECT_ENVELOPE_DIGEST", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "GETRANGE", "SET"] + }, + { + "id": "region-generation-init-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_cache_v1", + "registeredFunctionName": "ca_region_generation_init_v1", + "scriptResource": "redis/scripts/region-generation-init-v1.lua", + "sha256": "b12aa0645be45f8d184bf043efa9e62effbfc62b09bbfa503b45ecfc5aedf3d3", + "keyCount": 1, + "argumentCount": 2, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "generationKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "generationId", "type": "opaque-bytes", "maximumBytes": 64}, {"index": 2, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 64}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 129, "maximumEntries": 1}, + "ttl": {"mode": "OPTIONAL_PERSISTENT", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], + "statuses": ["INITIALIZED", "EXISTING", "WRONG_TYPE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "OPERATION_TOKEN_REPLAYABLE", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "GET", "SET", "PERSIST", "PEXPIRE"] + }, + { + "id": "region-generation-bump-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_cache_v1", + "registeredFunctionName": "ca_region_generation_bump_v1", + "scriptResource": "redis/scripts/region-generation-bump-v1.lua", + "sha256": "8c2e4b5a1e7c56b04612a1cf32cad69240ec68ef22ccf015c5d6ac1e3d4a6f94", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "generationKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "generationId", "type": "opaque-bytes", "maximumBytes": 64}, {"index": 2, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 64}, {"index": 3, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 64}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 129, "maximumEntries": 1}, + "ttl": {"mode": "OPTIONAL_PERSISTENT", "minimumMillis": 0, "maximumMillis": 2678400000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], + "statuses": ["BUMPED", "ALREADY_APPLIED", "WRONG_TYPE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "OPERATION_TOKEN_REPLAYABLE", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "GET", "SET", "PERSIST", "PEXPIRE"] + }, + { + "id": "cache-refresh-claim-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_cache_v1", + "registeredFunctionName": "ca_cache_refresh_claim_v1", + "scriptResource": "redis/scripts/cache-refresh-claim-v1.lua", + "sha256": "21a1e16956699b9e04573798ba22547cb4de84dd538ffe972e8073511ef9ce88", + "keyCount": 1, + "argumentCount": 3, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "refreshLeaseKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "ownerId", "type": "opaque-bytes", "maximumBytes": 64}, {"index": 2, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 64}, {"index": 3, "name": "ttlMillis", "type": "opaque-bytes", "maximumBytes": 64}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "string", "maximumBytes": 129, "maximumEntries": 1}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 300000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "key-type", "value-and-ttl-bounds"], + "statuses": ["CLAIMED", "ALREADY_OWNED", "CONTENDED", "WRONG_TYPE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "single-bounded-value", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "OPERATION_TOKEN_REPLAYABLE", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "GET", "SET"] } ] } diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json new file mode 100644 index 0000000..26bde4c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/rate-program-set.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 1, + "programSet": "ca-redis-rate-limit", + "semanticRevision": 2, + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 2, + "readiness": "R1", + "programs": [ + { + "id": "rate-fixed-window-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_rate_v1", + "registeredFunctionName": "ca_rate_fixed_window_v1", + "scriptResource": "redis/scripts/rate-fixed-window-v1.lua", + "sha256": "98906a0be4588a53bfeac21ee402b041eeae98304db0aa50d87d27ffef46c052", + "keyCount": 1, + "argumentCount": 7, + "replyFieldCount": 7, + "keys": [{"index": 1, "name": "stateKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "limit", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "cost", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 5, "name": "windowMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 6, "name": "cleanupGraceMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 7, "name": "maximumClockRegressionMillis", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 7, "maximumFieldBytes": 32, "orderedFields": ["status", "serverNowMillis", "effectiveNowMillis", "limit", "remaining", "retryAfterMillis", "resetAtMillis"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 4096, "maximumEntries": 16}, + "ttl": {"mode": "DERIVED_BOUNDED", "minimumMillis": 1, "maximumMillis": 172800000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "all-key-types", "policy-revision", "numeric-and-ttl-bounds", "dedup-bounds-when-present"], + "statuses": ["ALLOWED", "DENIED", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=16", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_WITHOUT_EVALUATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "PEXPIRE"] + }, + { + "id": "rate-sliding-counter-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_rate_v1", + "registeredFunctionName": "ca_rate_sliding_counter_v1", + "scriptResource": "redis/scripts/rate-sliding-counter-v1.lua", + "sha256": "8052763c037421a9dbc5887b16947b6944ade4d15fd33f4d7b8febe22b78fceb", + "keyCount": 1, + "argumentCount": 7, + "replyFieldCount": 7, + "keys": [{"index": 1, "name": "stateKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "limit", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "cost", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 5, "name": "windowMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 6, "name": "cleanupGraceMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 7, "name": "maximumClockRegressionMillis", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 7, "maximumFieldBytes": 32, "orderedFields": ["status", "serverNowMillis", "effectiveNowMillis", "limit", "remaining", "retryAfterMillis", "resetAtMillis"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 4096, "maximumEntries": 16}, + "ttl": {"mode": "DERIVED_BOUNDED", "minimumMillis": 1, "maximumMillis": 172800000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "all-key-types", "policy-revision", "numeric-and-ttl-bounds", "dedup-bounds-when-present"], + "statuses": ["ALLOWED", "DENIED", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=16", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_WITHOUT_EVALUATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "PEXPIRE"] + }, + { + "id": "rate-token-bucket-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_rate_v1", + "registeredFunctionName": "ca_rate_token_bucket_v1", + "scriptResource": "redis/scripts/rate-token-bucket-v1.lua", + "sha256": "83f70e72023d8eecc6525e5438b2031ff7417511cb1e5b38bf375b764cde84e1", + "keyCount": 1, + "argumentCount": 8, + "replyFieldCount": 7, + "keys": [{"index": 1, "name": "stateKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "capacityScaled", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "refillTokensScaled", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 5, "name": "refillPeriodMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 6, "name": "costScaled", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 7, "name": "cleanupGraceMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 8, "name": "maximumClockRegressionMillis", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 1, "fieldCount": 7, "maximumFieldBytes": 32, "orderedFields": ["status", "serverNowMillis", "effectiveNowMillis", "limit", "remaining", "retryAfterMillis", "resetAtMillis"]}, + "slotRule": "SINGLE_KEY", + "state": {"type": "hash", "maximumBytes": 4096, "maximumEntries": 16}, + "ttl": {"mode": "DERIVED_BOUNDED", "minimumMillis": 1, "maximumMillis": 172800000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "all-key-types", "policy-revision", "numeric-and-ttl-bounds", "dedup-bounds-when-present"], + "statuses": ["ALLOWED", "DENIED", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "fixed-hash-fields<=16", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "NOT_RETRY_SAFE_WITHOUT_EVALUATION_ID", + "timeoutCertainty": "INDETERMINATE", + "aclCommands": ["TYPE", "TIME", "HMGET", "HSET", "PEXPIRE"] + }, + { + "id": "rate-fixed-window-v2", + "semanticVersion": "2.0.0", + "libraryName": "ca_rate_v2", + "registeredFunctionName": "ca_rate_fixed_window_v2", + "scriptResource": "redis/scripts/rate-fixed-window-v2.lua", + "sha256": "c4bf60696c45bb1214fed031819583b8ef258ab49753c9c65b459e78e7e7a7ae", + "keyCount": 3, + "argumentCount": 11, + "replyFieldCount": 8, + "keys": [{"index": 1, "name": "stateKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "dedupHashKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 3, "name": "dedupOrderKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "limit", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "cost", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 5, "name": "windowMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 6, "name": "cleanupGraceMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 7, "name": "maximumClockRegressionMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 8, "name": "evaluationId", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 9, "name": "dedupTtlMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 10, "name": "maximumDedupEntries", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 11, "name": "maximumDedupBytes", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 2, "fieldCount": 8, "maximumFieldBytes": 32, "orderedFields": ["status", "decision", "serverNowMillis", "effectiveNowMillis", "limit", "remaining", "retryAfterMillis", "resetAtMillis"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "hash-and-zset", "maximumBytes": 262144, "maximumEntries": 1024}, + "ttl": {"mode": "DERIVED_BOUNDED", "minimumMillis": 1, "maximumMillis": 172800000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "all-key-types", "policy-revision", "numeric-and-ttl-bounds", "dedup-bounds-when-present"], + "statuses": ["ALLOWED", "DENIED", "DEDUP_REPLAY", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(log N), N<=1024", + "maximumIterations": 1024, + "stateGrowth": "bounded-dedup-entries<=1024-and-bytes<=262144", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_EVALUATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_EVALUATION_ID", + "aclCommands": ["TYPE", "TIME", "HMGET", "HGET", "HSET", "HDEL", "HLEN", "PEXPIRE", "ZSCORE", "ZADD", "ZREM", "ZCARD", "ZRANGEBYSCORE", "ZPOPMIN"] + }, + { + "id": "rate-sliding-counter-v2", + "semanticVersion": "2.0.0", + "libraryName": "ca_rate_v2", + "registeredFunctionName": "ca_rate_sliding_counter_v2", + "scriptResource": "redis/scripts/rate-sliding-counter-v2.lua", + "sha256": "737792cff7ba3f5c94c72ee828bcac9d4f1a35e6ff36a27765c3491bc0e1d918", + "keyCount": 3, + "argumentCount": 11, + "replyFieldCount": 8, + "keys": [{"index": 1, "name": "stateKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "dedupHashKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 3, "name": "dedupOrderKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "limit", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "cost", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 5, "name": "windowMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 6, "name": "cleanupGraceMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 7, "name": "maximumClockRegressionMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 8, "name": "evaluationId", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 9, "name": "dedupTtlMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 10, "name": "maximumDedupEntries", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 11, "name": "maximumDedupBytes", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 2, "fieldCount": 8, "maximumFieldBytes": 32, "orderedFields": ["status", "decision", "serverNowMillis", "effectiveNowMillis", "limit", "remaining", "retryAfterMillis", "resetAtMillis"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "hash-and-zset", "maximumBytes": 262144, "maximumEntries": 1024}, + "ttl": {"mode": "DERIVED_BOUNDED", "minimumMillis": 1, "maximumMillis": 172800000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "all-key-types", "policy-revision", "numeric-and-ttl-bounds", "dedup-bounds-when-present"], + "statuses": ["ALLOWED", "DENIED", "DEDUP_REPLAY", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(log N), N<=1024", + "maximumIterations": 1024, + "stateGrowth": "bounded-dedup-entries<=1024-and-bytes<=262144", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_EVALUATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_EVALUATION_ID", + "aclCommands": ["TYPE", "TIME", "HMGET", "HGET", "HSET", "HDEL", "HLEN", "PEXPIRE", "ZSCORE", "ZADD", "ZREM", "ZCARD", "ZRANGEBYSCORE", "ZPOPMIN"] + }, + { + "id": "rate-token-bucket-v2", + "semanticVersion": "2.0.0", + "libraryName": "ca_rate_v2", + "registeredFunctionName": "ca_rate_token_bucket_v2", + "scriptResource": "redis/scripts/rate-token-bucket-v2.lua", + "sha256": "df7aa2e2667e9d878f6f9745444f2318fe3e03ab94ec4a35e9cd2d0143e5bde2", + "keyCount": 3, + "argumentCount": 12, + "replyFieldCount": 8, + "keys": [{"index": 1, "name": "stateKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "dedupHashKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 3, "name": "dedupOrderKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "schemaVersion", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 2, "name": "policyRevision", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 3, "name": "capacityScaled", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 4, "name": "refillTokensScaled", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 5, "name": "refillPeriodMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 6, "name": "costScaled", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 7, "name": "cleanupGraceMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 8, "name": "maximumClockRegressionMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 9, "name": "evaluationId", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 10, "name": "dedupTtlMillis", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 11, "name": "maximumDedupEntries", "type": "opaque-bytes", "maximumBytes": 128}, {"index": 12, "name": "maximumDedupBytes", "type": "opaque-bytes", "maximumBytes": 128}], + "resultSchema": {"version": 2, "fieldCount": 8, "maximumFieldBytes": 32, "orderedFields": ["status", "decision", "serverNowMillis", "effectiveNowMillis", "limit", "remaining", "retryAfterMillis", "resetAtMillis"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "hash-and-zset", "maximumBytes": 262144, "maximumEntries": 1024}, + "ttl": {"mode": "DERIVED_BOUNDED", "minimumMillis": 1, "maximumMillis": 172800000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "all-key-types", "policy-revision", "numeric-and-ttl-bounds", "dedup-bounds-when-present"], + "statuses": ["ALLOWED", "DENIED", "DEDUP_REPLAY", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"], + "complexity": "O(log N), N<=1024", + "maximumIterations": 1024, + "stateGrowth": "bounded-dedup-entries<=1024-and-bytes<=262144", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_EVALUATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_EVALUATION_ID", + "aclCommands": ["TYPE", "TIME", "HMGET", "HGET", "HSET", "HDEL", "HLEN", "PEXPIRE", "ZSCORE", "ZADD", "ZREM", "ZCARD", "ZRANGEBYSCORE", "ZPOPMIN"] + } + ] +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-geo-admission-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-geo-admission-v1.lua new file mode 100644 index 0000000..c8526d1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-geo-admission-v1.lua @@ -0,0 +1,69 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function positive(value, maximum) + if not string.match(value, '^[1-9][0-9]*$') or #value > #maximum then + return false + end + return #value < #maximum or value <= maximum +end +local function coordinate(value, minimum, maximum) + if #value < 1 or #value > 20 or not string.match(value, '^-?[0-9]+%.?[0-9]*$') + or string.match(value, '^-?0[0-9]') + or string.sub(value, -1) == '.' + or value == '-0' then + return false + end + local parsed = tonumber(value) + return parsed ~= nil and parsed == parsed and parsed >= minimum and parsed <= maximum +end +if #KEYS ~= 1 or #ARGV ~= 5 then + return invalid('ARITY') +end +if #ARGV[1] < 1 or #ARGV[1] > 4096 + or not coordinate(ARGV[2], -180, 180) + or not coordinate(ARGV[3], -85.05112878, 85.05112878) + or not positive(ARGV[4], '1024') + or not positive(ARGV[5], '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'zset' then + return {'V1', 'WRONG_TYPE', '-'} +end +local missing = kind == 'none' +if not missing and redis.call('PTTL', key) <= 0 then + return {'V1', 'MISSING_TTL', '-'} +end +local cardinality = missing and 0 or redis.call('ZCARD', key) +local capacity = tonumber(ARGV[4]) +if cardinality > capacity then + return {'V1', 'STATE_OVER_CAPACITY', ARGV[4]} +end +local current = missing and false or redis.call('ZSCORE', key, ARGV[1]) +if not current and cardinality >= capacity then + return {'V1', 'CAPACITY_EXCEEDED', tostring(cardinality)} +end +if not redis.acl_check_cmd('type', key) + or (not missing and not redis.acl_check_cmd('pttl', key)) + or not redis.acl_check_cmd('zcard', key) + or not redis.acl_check_cmd('zscore', key, ARGV[1]) + or not redis.acl_check_cmd('geoadd', key, 'CH', ARGV[2], ARGV[3], ARGV[1]) + or (missing and + (not redis.acl_check_cmd('pexpire', key, ARGV[5]) + or not redis.acl_check_cmd('del', key))) then + return invalid('ACL') +end +local changed = redis.call('GEOADD', key, 'CH', ARGV[2], ARGV[3], ARGV[1]) +if missing then + local expiry = redis.pcall('PEXPIRE', key, ARGV[5]) + if expiry ~= 1 then + redis.call('DEL', key) + return {'V1', 'TTL_APPLY_FAILED', '-'} + end +end +if not current then + return {'V1', 'ADDED', tostring(cardinality + 1)} +end +return {'V1', changed == 1 and 'POSITION_CHANGED' or 'UNCHANGED', tostring(cardinality)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-get-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-get-v1.lua new file mode 100644 index 0000000..a84b2e4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-get-v1.lua @@ -0,0 +1,9 @@ +local limit = tonumber(ARGV[1]) +local value = redis.call('GETRANGE', KEYS[1], 0, limit) +if #value > limit then + return redis.error_reply('CA_VALUE_TOO_LARGE') +end +if #value == 0 and redis.call('EXISTS', KEYS[1]) == 0 then + return false +end +return value diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-field-admission-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-field-admission-v1.lua new file mode 100644 index 0000000..48ebefb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-field-admission-v1.lua @@ -0,0 +1,56 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function positive(value, maximum) + if not string.match(value, '^[1-9][0-9]*$') or #value > #maximum then + return false + end + return #value < #maximum or value <= maximum +end +if #KEYS ~= 1 or #ARGV ~= 4 then + return invalid('ARITY') +end +if #ARGV[1] < 1 or #ARGV[1] > 1024 + or #ARGV[2] < 1 or #ARGV[2] > 1048448 + or not positive(ARGV[3], '1024') + or not positive(ARGV[4], '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'hash' then + return {'V1', 'WRONG_TYPE', '-'} +end +local missing = kind == 'none' +if not missing and redis.call('PTTL', key) <= 0 then + return {'V1', 'MISSING_TTL', '-'} +end +local cardinality = missing and 0 or redis.call('HLEN', key) +local capacity = tonumber(ARGV[3]) +if cardinality > capacity then + return {'V1', 'STATE_OVER_CAPACITY', ARGV[3]} +end +local exists = missing and 0 or redis.call('HEXISTS', key, ARGV[1]) +if exists == 0 and cardinality >= capacity then + return {'V1', 'CAPACITY_EXCEEDED', tostring(cardinality)} +end +if not redis.acl_check_cmd('type', key) + or (not missing and not redis.acl_check_cmd('pttl', key)) + or not redis.acl_check_cmd('hlen', key) + or not redis.acl_check_cmd('hexists', key, ARGV[1]) + or not redis.acl_check_cmd('hset', key, ARGV[1], ARGV[2]) + or (missing and + (not redis.acl_check_cmd('pexpire', key, ARGV[4]) + or not redis.acl_check_cmd('del', key))) then + return invalid('ACL') +end +redis.call('HSET', key, ARGV[1], ARGV[2]) +if missing then + local expiry = redis.pcall('PEXPIRE', key, ARGV[4]) + if expiry ~= 1 then + redis.call('DEL', key) + return {'V1', 'TTL_APPLY_FAILED', '-'} + end +end +local nextCardinality = exists == 1 and cardinality or cardinality + 1 +return {'V1', exists == 1 and 'SET_EXISTING' or 'ADMITTED', tostring(nextCardinality)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-scan-page-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-scan-page-v1.lua new file mode 100644 index 0000000..ed1bccc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-hash-scan-page-v1.lua @@ -0,0 +1,49 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function cursor(value) + return #value <= 20 + and (value == '0' or string.match(value, '^[1-9][0-9]*$')) +end +if #KEYS ~= 1 or #ARGV ~= 3 + or not cursor(ARGV[1]) + or not string.match(ARGV[2], '^[1-9][0-9]*$') + or #ARGV[2] > 4 + or not string.match(ARGV[3], '^[1-9][0-9]*$') + or #ARGV[3] > 7 then + return invalid('INPUT') +end +local ceiling = tonumber(ARGV[2]) +local maximumBytes = tonumber(ARGV[3]) +if ceiling > 1024 or maximumBytes > 2097152 then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind == 'none' then + return {'V1', 'PAGE', '1:0'} +end +if kind ~= 'hash' then + return {'V1', 'WRONG_TYPE', '-'} +end +if redis.call('HLEN', key) > ceiling then + return {'V1', 'STATE_OVER_CAPACITY', ARGV[2]} +end +if not redis.acl_check_cmd('type', key) + or not redis.acl_check_cmd('hlen', key) + or not redis.acl_check_cmd('hscan', key, ARGV[1], 'COUNT', ARGV[2]) then + return invalid('ACL') +end +local page = redis.call('HSCAN', key, ARGV[1], 'COUNT', ARGV[2]) +local packed = {tostring(#page[1]) .. ':' .. page[1]} +local bytes = #packed[1] +for index = 1, #page[2] do + local value = page[2][index] + local encoded = tostring(#value) .. ':' .. value + bytes = bytes + #encoded + if bytes > maximumBytes then + return {'V1', 'TOO_LARGE', '-'} + end + table.insert(packed, encoded) +end +return {'V1', 'PAGE', table.concat(packed)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-list-admission-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-list-admission-v1.lua new file mode 100644 index 0000000..6496cc7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-list-admission-v1.lua @@ -0,0 +1,48 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +if #KEYS ~= 1 or #ARGV ~= 3 then + return invalid('ARITY') +end +if #ARGV[1] < 1 or #ARGV[1] > 4096 + or not string.match(ARGV[2], '^[1-9][0-9]*$') + or #ARGV[2] > 4 or (#ARGV[2] == 4 and ARGV[2] > '1024') + or not string.match(ARGV[3], '^[1-9][0-9]*$') + or #ARGV[3] > 10 or (#ARGV[3] == 10 and ARGV[3] > '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'list' then + return {'V1', 'WRONG_TYPE', '-'} +end +local missing = kind == 'none' +if not missing then + local remaining = redis.call('PTTL', key) + if remaining <= 0 then + return {'V1', 'MISSING_TTL', '-'} + end +end +local length = missing and 0 or redis.call('LLEN', key) +local capacity = tonumber(ARGV[2]) +if length >= capacity then + return {'V1', 'CAPACITY_EXCEEDED', tostring(length)} +end +if not redis.acl_check_cmd('type', key) + or (not missing and not redis.acl_check_cmd('pttl', key)) + or not redis.acl_check_cmd('llen', key) + or not redis.acl_check_cmd('rpush', key, ARGV[1]) + or (missing and + (not redis.acl_check_cmd('pexpire', key, ARGV[3]) + or not redis.acl_check_cmd('del', key))) then + return invalid('ACL') +end +redis.call('RPUSH', key, ARGV[1]) +if missing then + local expiry = redis.pcall('PEXPIRE', key, ARGV[3]) + if expiry ~= 1 then + redis.call('DEL', key) + return {'V1', 'TTL_APPLY_FAILED', '-'} + end +end +return {'V1', 'ADMITTED', tostring(length + 1)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-mget-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-mget-v1.lua new file mode 100644 index 0000000..97254cb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-mget-v1.lua @@ -0,0 +1,53 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +if #KEYS ~= 4 or #ARGV ~= 3 + or not string.match(ARGV[1], '^[1-4]$') + or not string.match(ARGV[2], '^[1-9][0-9]*$') + or #ARGV[2] > 7 or (#ARGV[2] == 7 and ARGV[2] > '2097152') + or not string.match(ARGV[3], '^[1-9][0-9]*$') + or #ARGV[3] > 7 or (#ARGV[3] == 7 and ARGV[3] > '1048576') then + return invalid('INPUT') +end +local requested = tonumber(ARGV[1]) +local maximumBytes = tonumber(ARGV[2]) +local maximumValueBytes = tonumber(ARGV[3]) +local lengths = {} +local total = 0 +for index = 1, requested do + local kind = redis.call('TYPE', KEYS[index]).ok + if kind ~= 'none' and kind ~= 'string' then + return {'V1', 'WRONG_TYPE', tostring(index)} + end + if kind == 'none' then + lengths[index] = -1 + total = total + 3 + else + lengths[index] = redis.call('STRLEN', KEYS[index]) + if lengths[index] > maximumValueBytes then + return {'V1', 'VALUE_TOO_LARGE', tostring(index)} + end + total = total + lengths[index] + 24 + end + if total > maximumBytes then + return {'V1', 'TOO_LARGE', '-'} + end +end +for index = 1, requested do + if not redis.acl_check_cmd('type', KEYS[index]) + or (lengths[index] >= 0 + and (not redis.acl_check_cmd('strlen', KEYS[index]) + or not redis.acl_check_cmd('get', KEYS[index]))) then + return invalid('ACL') + end +end +local packed = {} +for index = 1, requested do + if lengths[index] < 0 then + table.insert(packed, '-1:') + else + local value = redis.call('GET', KEYS[index]) + table.insert(packed, tostring(#value) .. ':' .. value) + end +end +return {'V1', 'OK', table.concat(packed)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-admission-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-admission-v1.lua new file mode 100644 index 0000000..3f2b442 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-admission-v1.lua @@ -0,0 +1,53 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +if #KEYS ~= 1 or #ARGV ~= 3 then + return invalid('ARITY') +end +if #ARGV[1] < 1 or #ARGV[1] > 4096 + or not string.match(ARGV[2], '^[1-9][0-9]*$') + or #ARGV[2] > 4 or (#ARGV[2] == 4 and ARGV[2] > '1024') + or not string.match(ARGV[3], '^[1-9][0-9]*$') + or #ARGV[3] > 10 or (#ARGV[3] == 10 and ARGV[3] > '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'set' then + return {'V1', 'WRONG_TYPE', '-'} +end +local missing = kind == 'none' +if not missing then + local remaining = redis.call('PTTL', key) + if remaining <= 0 then + return {'V1', 'MISSING_TTL', '-'} + end +end +local present = missing and 0 or redis.call('SISMEMBER', key, ARGV[1]) +local cardinality = missing and 0 or redis.call('SCARD', key) +if present == 1 then + return {'V1', 'ALREADY_PRESENT', tostring(cardinality)} +end +local capacity = tonumber(ARGV[2]) +if cardinality >= capacity then + return {'V1', 'CAPACITY_EXCEEDED', tostring(cardinality)} +end +if not redis.acl_check_cmd('type', key) + or (not missing and not redis.acl_check_cmd('pttl', key)) + or not redis.acl_check_cmd('sismember', key, ARGV[1]) + or not redis.acl_check_cmd('scard', key) + or not redis.acl_check_cmd('sadd', key, ARGV[1]) + or (missing and + (not redis.acl_check_cmd('pexpire', key, ARGV[3]) + or not redis.acl_check_cmd('del', key))) then + return invalid('ACL') +end +redis.call('SADD', key, ARGV[1]) +if missing then + local expiry = redis.pcall('PEXPIRE', key, ARGV[3]) + if expiry ~= 1 then + redis.call('DEL', key) + return {'V1', 'TTL_APPLY_FAILED', '-'} + end +end +return {'V1', 'ADMITTED', tostring(cardinality + 1)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-scan-page-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-scan-page-v1.lua new file mode 100644 index 0000000..1d51a93 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-set-scan-page-v1.lua @@ -0,0 +1,49 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function cursor(value) + return #value <= 20 + and (value == '0' or string.match(value, '^[1-9][0-9]*$')) +end +if #KEYS ~= 1 or #ARGV ~= 3 + or not cursor(ARGV[1]) + or not string.match(ARGV[2], '^[1-9][0-9]*$') + or #ARGV[2] > 4 + or not string.match(ARGV[3], '^[1-9][0-9]*$') + or #ARGV[3] > 7 then + return invalid('INPUT') +end +local ceiling = tonumber(ARGV[2]) +local maximumBytes = tonumber(ARGV[3]) +if ceiling > 1024 or maximumBytes > 2097152 then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind == 'none' then + return {'V1', 'PAGE', '1:0'} +end +if kind ~= 'set' then + return {'V1', 'WRONG_TYPE', '-'} +end +if redis.call('SCARD', key) > ceiling then + return {'V1', 'STATE_OVER_CAPACITY', ARGV[2]} +end +if not redis.acl_check_cmd('type', key) + or not redis.acl_check_cmd('scard', key) + or not redis.acl_check_cmd('sscan', key, ARGV[1], 'COUNT', ARGV[2]) then + return invalid('ACL') +end +local page = redis.call('SSCAN', key, ARGV[1], 'COUNT', ARGV[2]) +local packed = {tostring(#page[1]) .. ':' .. page[1]} +local bytes = #packed[1] +for index = 1, #page[2] do + local value = page[2][index] + local encoded = tostring(#value) .. ':' .. value + bytes = bytes + #encoded + if bytes > maximumBytes then + return {'V1', 'TOO_LARGE', '-'} + end + table.insert(packed, encoded) +end +return {'V1', 'PAGE', table.concat(packed)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-zset-admission-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-zset-admission-v1.lua new file mode 100644 index 0000000..8bcb79c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/bounded-zset-admission-v1.lua @@ -0,0 +1,69 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function positive(value, maximum) + if not string.match(value, '^[1-9][0-9]*$') or #value > #maximum then + return false + end + return #value < #maximum or value <= maximum +end +local function finite_score(value) + if #value < 1 or #value > 27 or not string.match(value, '^-?[0-9]+%.?[0-9]*$') + or string.match(value, '^-?0[0-9]') + or string.sub(value, -1) == '.' + or value == '-0' then + return false + end + local parsed = tonumber(value) + return parsed ~= nil and parsed == parsed + and parsed >= -1000000000000000 and parsed <= 1000000000000000 +end +if #KEYS ~= 1 or #ARGV ~= 4 then + return invalid('ARITY') +end +if #ARGV[1] < 1 or #ARGV[1] > 4096 + or not finite_score(ARGV[2]) + or not positive(ARGV[3], '1024') + or not positive(ARGV[4], '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'zset' then + return {'V1', 'WRONG_TYPE', '-'} +end +local missing = kind == 'none' +if not missing and redis.call('PTTL', key) <= 0 then + return {'V1', 'MISSING_TTL', '-'} +end +local cardinality = missing and 0 or redis.call('ZCARD', key) +local capacity = tonumber(ARGV[3]) +if cardinality > capacity then + return {'V1', 'STATE_OVER_CAPACITY', ARGV[3]} +end +local current = missing and false or redis.call('ZSCORE', key, ARGV[1]) +if not current and cardinality >= capacity then + return {'V1', 'CAPACITY_EXCEEDED', tostring(cardinality)} +end +if not redis.acl_check_cmd('type', key) + or (not missing and not redis.acl_check_cmd('pttl', key)) + or not redis.acl_check_cmd('zcard', key) + or not redis.acl_check_cmd('zscore', key, ARGV[1]) + or not redis.acl_check_cmd('zadd', key, 'CH', ARGV[2], ARGV[1]) + or (missing and + (not redis.acl_check_cmd('pexpire', key, ARGV[4]) + or not redis.acl_check_cmd('del', key))) then + return invalid('ACL') +end +local changed = redis.call('ZADD', key, 'CH', ARGV[2], ARGV[1]) +if missing then + local expiry = redis.pcall('PEXPIRE', key, ARGV[4]) + if expiry ~= 1 then + redis.call('DEL', key) + return {'V1', 'TTL_APPLY_FAILED', '-'} + end +end +if not current then + return {'V1', 'ADDED', tostring(cardinality + 1)} +end +return {'V1', changed == 1 and 'SCORE_CHANGED' or 'UNCHANGED', tostring(cardinality)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/cache-refresh-claim-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/cache-refresh-claim-v1.lua new file mode 100644 index 0000000..c658335 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/cache-refresh-claim-v1.lua @@ -0,0 +1,66 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function valid_token(value) + return value ~= nil + and string.len(value) >= 16 + and string.len(value) <= 64 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function valid_ttl(value) + return value ~= nil + and string.match(value, '^%d+$') ~= nil + and tonumber(value) ~= nil + and tonumber(value) >= 1 + and tonumber(value) <= 300000 +end + +local function valid_state(value) + local separator = string.find(value, '|', 1, true) + if separator == nil or string.find(value, '|', separator + 1, true) ~= nil then + return false + end + return valid_token(string.sub(value, 1, separator - 1)) + and valid_token(string.sub(value, separator + 1)) +end + +if #KEYS ~= 1 or #ARGV ~= 3 + or not valid_token(ARGV[1]) + or not valid_token(ARGV[2]) + or not valid_ttl(ARGV[3]) then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type ~= 'none' and current_type ~= 'string' then + return 'WRONG_TYPE' +end + +local requested = ARGV[1] .. '|' .. ARGV[2] +if current_type == 'string' then + local current = redis.call('GET', KEYS[1]) + if not valid_state(current) then + return 'INVALID' + end + if current == requested then + return 'ALREADY_OWNED' + end + return 'CONTENDED' +end + +local applied = redis.call('SET', KEYS[1], requested, 'PX', ARGV[3], 'NX') +if applied then + return 'CLAIMED' +end + +local winner = redis.call('GET', KEYS[1]) +if winner ~= false and winner == requested then + return 'ALREADY_OWNED' +end +return 'CONTENDED' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-set-with-ttl-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-set-with-ttl-v1.lua new file mode 100644 index 0000000..24af714 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/compare-and-set-with-ttl-v1.lua @@ -0,0 +1,35 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +if #KEYS ~= 1 or #ARGV ~= 4 then + return invalid('ARITY') +end +if (ARGV[1] ~= 'ABSENT' and ARGV[1] ~= 'VALUE') + or (ARGV[1] == 'ABSENT' and ARGV[2] ~= '-') + or #ARGV[2] > 1048576 or #ARGV[3] > 1048576 + or not string.match(ARGV[4], '^[1-9][0-9]*$') + or #ARGV[4] > 10 + or (#ARGV[4] == 10 and ARGV[4] > '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'string' then + return {'V1', 'WRONG_TYPE', '-'} +end +local current = nil +if kind == 'string' then + current = redis.call('GET', key) +end +local matched = (ARGV[1] == 'ABSENT' and kind == 'none') + or (ARGV[1] == 'VALUE' and kind == 'string' and current == ARGV[2]) +if not matched then + return {'V1', 'MISMATCH', '-'} +end +if not redis.acl_check_cmd('type', key) + or (kind == 'string' and not redis.acl_check_cmd('get', key)) + or not redis.acl_check_cmd('set', key, ARGV[3], 'PX', ARGV[4]) then + return invalid('ACL') +end +redis.call('SET', key, ARGV[3], 'PX', ARGV[4]) +return {'V1', 'UPDATED', '-'} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/guarded-list-trim-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/guarded-list-trim-v1.lua new file mode 100644 index 0000000..c4e680d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/guarded-list-trim-v1.lua @@ -0,0 +1,48 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function positive(value) + return string.match(value, '^[1-9][0-9]*$') + and (#value < 4 or (#value == 4 and value <= '1024')) +end +if #KEYS ~= 1 or #ARGV ~= 2 then + return invalid('ARITY') +end +if not positive(ARGV[1]) or not positive(ARGV[2]) then + return invalid('INPUT') +end +local retain = tonumber(ARGV[1]) +local maximum = tonumber(ARGV[2]) +if retain + maximum > 2048 then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind == 'none' then + return {'V1', 'TRIMMED', '0'} +end +if kind ~= 'list' then + return {'V1', 'WRONG_TYPE', '-'} +end +if redis.call('PTTL', key) <= 0 then + return {'V1', 'MISSING_TTL', '-'} +end +local length = redis.call('LLEN', key) +if length <= retain then + return {'V1', 'TRIMMED', '0'} +end +local removals = length - retain +if removals > maximum then + return {'V1', 'TOO_EXPENSIVE', ARGV[2]} +end +if not redis.acl_check_cmd('type', key) + or not redis.acl_check_cmd('pttl', key) + or not redis.acl_check_cmd('llen', key) + or not redis.acl_check_cmd('ltrim', key, -retain, -1) then + return invalid('ACL') +end +local trimmed = redis.call('LTRIM', key, -retain, -1) +if trimmed ~= 'OK' then + return {'V1', 'CORRUPT_AFTER_WRITE', 'RESULT'} +end +return {'V1', 'TRIMMED', tostring(removals)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/hash-revision-cas-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/hash-revision-cas-v1.lua new file mode 100644 index 0000000..b13f6d5 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/hash-revision-cas-v1.lua @@ -0,0 +1,70 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +if #KEYS ~= 1 or #ARGV ~= 5 then + return invalid('ARITY') +end +local function token(value) + return #value >= 1 and #value <= 64 and string.match(value, '^[A-Za-z0-9_-]+$') +end +if (ARGV[1] ~= 'ABSENT' and ARGV[1] ~= 'VALUE') + or (ARGV[1] == 'ABSENT' and ARGV[2] ~= '-') + or (ARGV[1] == 'VALUE' and not token(ARGV[2])) + or not token(ARGV[3]) + or ARGV[2] == ARGV[3] + or #ARGV[4] < 1 or #ARGV[4] > 1048448 + or not string.match(ARGV[5], '^[1-9][0-9]*$') + or #ARGV[5] > 10 or (#ARGV[5] == 10 and ARGV[5] > '2678400000') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'hash' then + return {'V1', 'WRONG_TYPE', '-'} +end +local missing = kind == 'none' +if not redis.acl_check_cmd('type', key) + or (not missing and + (not redis.acl_check_cmd('pttl', key) + or not redis.acl_check_cmd('hlen', key) + or not redis.acl_check_cmd('hexists', key, '_revision') + or not redis.acl_check_cmd('hexists', key, 'value') + or not redis.acl_check_cmd('hget', key, '_revision'))) + or not redis.acl_check_cmd('hset', key, '_revision', ARGV[3], 'value', ARGV[4]) + or (missing and + (not redis.acl_check_cmd('pexpire', key, ARGV[5]) + or not redis.acl_check_cmd('del', key))) then + return invalid('ACL') +end +if not missing then + local remaining = redis.call('PTTL', key) + if remaining <= 0 then + return {'V1', 'MISSING_TTL', '-'} + end + if redis.call('HLEN', key) ~= 2 + or redis.call('HEXISTS', key, '_revision') ~= 1 + or redis.call('HEXISTS', key, 'value') ~= 1 then + return {'V1', 'MALFORMED_REVISION', '-'} + end +end +local current = missing and false or redis.call('HGET', key, '_revision') +if not missing and not current then + return {'V1', 'MALFORMED_REVISION', '-'} +end +if current and not token(current) then + return {'V1', 'MALFORMED_REVISION', '-'} +end +local matched = (ARGV[1] == 'ABSENT' and not current) + or (ARGV[1] == 'VALUE' and current == ARGV[2]) +if not matched then + return {'V1', 'MISMATCH', current or 'ABSENT'} +end +redis.call('HSET', key, '_revision', ARGV[3], 'value', ARGV[4]) +if missing then + local expiry = redis.pcall('PEXPIRE', key, ARGV[5]) + if expiry ~= 1 then + redis.call('DEL', key) + return {'V1', 'TTL_APPLY_FAILED', '-'} + end +end +return {'V1', 'UPDATED', ARGV[3]} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-claim-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-claim-v1.lua new file mode 100644 index 0000000..006c0a8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-claim-v1.lua @@ -0,0 +1,290 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_EXACT = 9007199254740991 +local MAX_ATTEMPT = 1000000000 +local MAX_PROCESSING_TTL = 86400000 +local MAX_RECORD_TTL = 2592000000 +local MAX_RESPONSE_BYTES = 10924 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function response_payload(value) + if type(value) ~= 'string' or #value < 1 or #value > MAX_RESPONSE_BYTES then + return false + end + if value == '-' then return true end + return string.match(value, '^[A-Za-z0-9_-]+$') ~= nil and (#value % 4) ~= 1 +end + +local function digest(value) + return type(value) == 'string' + and #value == 64 + and string.match(value, '^[0-9a-f]+$') ~= nil +end + +local function reply(status, attempt, expires_at, payload, digest, operation) + return { + status, + tostring(attempt), + tostring(expires_at), + payload or '-', + digest or '-', + operation or '-' + } +end + +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local schema = integer(ARGV[1], 9999) +local fingerprint = ARGV[2] +local owner = ARGV[3] +local operation = ARGV[4] +local processing_ttl = integer(ARGV[5], MAX_PROCESSING_TTL) +local record_ttl = integer(ARGV[6], MAX_RECORD_TTL) +local codec = ARGV[7] +local revision = ARGV[8] +if schema ~= 2 + or type(fingerprint) ~= 'string' or string.match(fingerprint, '^[0-9a-f]+$') == nil + or #fingerprint ~= 64 + or not token(owner) + or not token(operation) + or processing_ttl == nil or processing_ttl < 1 + or record_ttl == nil or record_ttl <= processing_ttl + or not identifier(codec) + or not identifier(revision) + or now > MAX_EXACT then + return reply('INVALID', 0, 0) +end + +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +local lease_until = now + processing_ttl +if lease_until > MAX_EXACT then return reply('INVALID', 0, 0) end +if state_type == 'none' then + redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'state', 'CLAIMED', + 'fingerprint', fingerprint, + 'ownerToken', owner, + 'attempt', '1', + 'claimOperation', operation, + 'leaseUntil', tostring(lease_until), + 'responseCodecId', codec, + 'policyRevision', revision, + 'recordTtlMillis', tostring(record_ttl), + 'stateRevision', '1', + 'updatedAtMillis', tostring(now)) + redis.call('PEXPIRE', KEYS[1], tostring(record_ttl)) + return reply('ACQUIRED', 1, lease_until) +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'fingerprint', + 'ownerToken', + 'attempt', + 'claimOperation', + 'leaseUntil', + 'responseCodecId', + 'policyRevision', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'recordTtlMillis', + 'stateRevision', + 'updatedAtMillis', + 'startOperation', + 'renewOperation', + 'renewLeaseUntil', + 'failureOperation', + 'failureDisposition', + 'releaseOperation', + 'completeOperation') +local state = stored[2] +local attempt = integer(stored[5], MAX_ATTEMPT) +local stored_lease = integer(stored[7], MAX_EXACT) +local stored_record_ttl = integer(stored[13], MAX_RECORD_TTL) +local state_revision = integer(stored[14], MAX_EXACT) +local updated_at = integer(stored[15], MAX_EXACT) +if stored[1] ~= ARGV[1] + or state == false + or stored[3] == false + or string.match(stored[3], '^[0-9a-f]+$') == nil or #stored[3] ~= 64 + or not token(stored[4]) + or attempt == nil or attempt < 1 + or not token(stored[6]) + or stored_lease == nil + or stored[8] ~= codec + or stored[9] ~= revision + or stored_record_ttl == nil or stored_record_ttl < 1 + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if stored[3] ~= fingerprint then return reply('FINGERPRINT_MISMATCH', attempt, 0) end + +local renew_present = stored[17] ~= false or stored[18] ~= false +if renew_present + and (not token(stored[17]) or integer(stored[18], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +local shape_valid = + (state == 'CLAIMED' + and stored_lease > 0 + and stored[16] == false + and stored[10] == false and stored[11] == false and stored[12] == false + and stored[19] == false and stored[20] == false and stored[21] == false + and stored[22] == false) + or (state == 'EXECUTING' + and stored_lease > 0 + and token(stored[16]) + and stored[10] == false and stored[11] == false and stored[12] == false + and stored[19] == false and stored[20] == false and stored[21] == false + and stored[22] == false) + or (state == 'COMPLETED' + and stored_lease == 0 + and token(stored[16]) + and token(stored[22]) + and response_payload(stored[10]) and digest(stored[11]) and stored[12] ~= false + and stored[19] == false and stored[20] == false and stored[21] == false + and not renew_present) + or (state == 'FAILED_RETRYABLE' + and stored_lease == 0 + and token(stored[16]) + and token(stored[19]) and stored[20] == 'RETRYABLE_NO_EFFECT' + and stored[10] == false and stored[11] == false and stored[12] == false + and stored[21] == false and stored[22] == false + and not renew_present) + or (state == 'ABANDONED' + and stored_lease == 0 + and token(stored[16]) + and token(stored[19]) and stored[20] == 'ABANDONED_EFFECT_UNKNOWN' + and stored[10] == false and stored[11] == false and stored[12] == false + and stored[21] == false and stored[22] == false + and not renew_present) + or (state == 'RELEASED' + and stored_lease == 0 + and token(stored[21]) + and stored[16] == false + and stored[10] == false and stored[11] == false and stored[12] == false + and stored[19] == false and stored[20] == false and stored[22] == false + and not renew_present) +if not shape_valid then return reply('STATE_INCOMPATIBLE', 0, 0) end + +if state == 'COMPLETED' then + local replay_until = integer(stored[12], MAX_EXACT) + if stored[10] == false or stored[11] == false or replay_until == nil then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + if replay_until > now then + return reply('COMPLETED_REPLAY', attempt, replay_until, stored[10], stored[11]) + end +end +if state == 'EXECUTING' then + if stored_lease <= now then + redis.call( + 'HSET', + KEYS[1], + 'state', 'ABANDONED', + 'failureOperation', stored[16], + 'failureDisposition', 'ABANDONED_EFFECT_UNKNOWN', + 'leaseUntil', '0', + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) + redis.call('HDEL', KEYS[1], 'renewOperation', 'renewLeaseUntil') + redis.call('PEXPIRE', KEYS[1], tostring(stored_record_ttl)) + return reply('RECOVERY_REQUIRED', attempt, 0) + end + if (stored[4] == owner) ~= (stored[6] == operation) then + return reply('OWNER_OPERATION_CONFLICT', attempt, stored_lease) + end + return reply('IN_PROGRESS', attempt, math.max(1, stored_lease - now)) +end +if state == 'ABANDONED' then return reply('RECOVERY_REQUIRED', attempt, 0) end + +local same_claim = stored[4] == owner and stored[6] == operation +if (stored[4] == owner) ~= (stored[6] == operation) then + return reply('OWNER_OPERATION_CONFLICT', attempt, stored_lease) +end +if state == 'CLAIMED' and same_claim then + if stored_lease <= now then + redis.call( + 'HSET', + KEYS[1], + 'leaseUntil', tostring(lease_until), + 'recordTtlMillis', tostring(record_ttl), + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) + redis.call('PEXPIRE', KEYS[1], tostring(record_ttl)) + return reply('REPLAYED_ACQUIRE', attempt, lease_until) + end + return reply('REPLAYED_ACQUIRE', attempt, stored_lease) +end +if state == 'CLAIMED' and stored_lease > now then + return reply('IN_PROGRESS', attempt, math.max(1, stored_lease - now)) +end +if state ~= 'CLAIMED' and state ~= 'FAILED_RETRYABLE' and state ~= 'RELEASED' then + if state ~= 'COMPLETED' then return reply('STATE_INCOMPATIBLE', 0, 0) end +end +if state ~= 'CLAIMED' and same_claim then + return reply('OWNER_OPERATION_CONFLICT', attempt, stored_lease) +end +if attempt >= MAX_ATTEMPT then return reply('STATE_INCOMPATIBLE', 0, 0) end +local next_attempt = attempt + 1 +redis.call( + 'HSET', + KEYS[1], + 'state', 'CLAIMED', + 'ownerToken', owner, + 'attempt', tostring(next_attempt), + 'claimOperation', operation, + 'leaseUntil', tostring(lease_until), + 'recordTtlMillis', tostring(record_ttl), + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) +redis.call( + 'HDEL', + KEYS[1], + 'startOperation', + 'renewOperation', + 'completeOperation', + 'failureOperation', + 'releaseOperation', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'failureDisposition') +redis.call('PEXPIRE', KEYS[1], tostring(record_ttl)) +return reply('TAKEN_OVER_CLAIMED', next_attempt, lease_until) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-complete-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-complete-v1.lua new file mode 100644 index 0000000..4c78118 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-complete-v1.lua @@ -0,0 +1,185 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_ATTEMPT = 1000000000 +local MAX_REPLAY_TTL = 2592000000 +local MAX_RESPONSE_BYTES = 10924 +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function response_payload(value) + if type(value) ~= 'string' or #value < 1 or #value > MAX_RESPONSE_BYTES then + return false + end + if value == '-' then return true end + return string.match(value, '^[A-Za-z0-9_-]+$') ~= nil and (#value % 4) ~= 1 +end + +local function reply(status, attempt, expires_at, payload, digest, operation) + return { + status, + tostring(attempt), + tostring(expires_at), + payload or '-', + digest or '-', + operation or '-' + } +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local attempt = integer(ARGV[3], MAX_ATTEMPT) +local payload = ARGV[4] +local digest = ARGV[5] +local replay_ttl = integer(ARGV[6], MAX_REPLAY_TTL) +local operation = ARGV[7] +if schema ~= 2 or not token(owner) or attempt == nil or attempt < 1 + or not response_payload(payload) + or type(digest) ~= 'string' or #digest ~= 64 + or string.match(digest, '^[0-9a-f]+$') == nil + or replay_ttl == nil or replay_ttl < 1 or not token(operation) then + return reply('INVALID', 0, 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'ownerToken', + 'attempt', + 'completeOperation', + 'responseDigest', + 'responsePayload', + 'replayUntil', + 'startOperation', + 'stateRevision', + 'updatedAtMillis', + 'failureOperation', + 'releaseOperation', + 'leaseUntil', + 'recordTtlMillis', + 'renewOperation', + 'renewLeaseUntil', + 'fingerprint', + 'claimOperation', + 'responseCodecId', + 'policyRevision') +local stored_attempt = integer(stored[4], MAX_ATTEMPT) +local state_revision = integer(stored[10], MAX_EXACT) +local updated_at = integer(stored[11], MAX_EXACT) +local lease_until = integer(stored[14], MAX_EXACT) +local record_ttl = integer(stored[15], MAX_REPLAY_TTL) +if stored[1] ~= ARGV[1] or stored[2] == false or stored[3] == false + or stored_attempt == nil or not token(stored[9]) + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now + or stored[12] ~= false or stored[13] ~= false + or lease_until == nil or record_ttl == nil or record_ttl < 1 + or stored[18] == false or string.match(stored[18], '^[0-9a-f]+$') == nil + or #stored[18] ~= 64 or not token(stored[19]) + or not identifier(stored[20]) or not identifier(stored[21]) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +local renew_present = stored[16] ~= false or stored[17] ~= false +if renew_present + and (not token(stored[16]) or integer(stored[17], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if stored[3] ~= owner or stored_attempt ~= attempt then + return reply('NOT_OWNER', stored_attempt, 0) +end +if stored[2] == 'COMPLETED' then + if lease_until ~= 0 or renew_present then return reply('STATE_INCOMPATIBLE', 0, 0) end + if stored[5] ~= operation then return reply('OPERATION_CONFLICT', stored_attempt, 0) end + if stored[6] ~= digest or stored[7] ~= payload then + return reply('RESPONSE_CONFLICT', stored_attempt, 0) + end + local replay_until = integer(stored[8], MAX_EXACT) + if replay_until == nil then return reply('STATE_INCOMPATIBLE', 0, 0) end + return reply( + 'ALREADY_COMPLETED_SAME_RESULT', + stored_attempt, + replay_until, + stored[7], + stored[6], + stored[5]) +end +if stored[2] ~= 'EXECUTING' then return reply('NOT_IN_PROGRESS', stored_attempt, 0) end +if stored[5] ~= false or stored[6] ~= false or stored[7] ~= false or stored[8] ~= false then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if lease_until <= now then + redis.call( + 'HSET', + KEYS[1], + 'state', 'ABANDONED', + 'failureOperation', stored[9], + 'failureDisposition', 'ABANDONED_EFFECT_UNKNOWN', + 'leaseUntil', '0', + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) + redis.call( + 'HDEL', + KEYS[1], + 'renewOperation', + 'renewLeaseUntil', + 'completeOperation', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'releaseOperation') + redis.call('PEXPIRE', KEYS[1], tostring(record_ttl)) + return reply('NOT_IN_PROGRESS', stored_attempt, 0) +end +local replay_until = now + replay_ttl +redis.call( + 'HSET', + KEYS[1], + 'state', 'COMPLETED', + 'completeOperation', operation, + 'responsePayload', payload, + 'responseDigest', digest, + 'replayUntil', tostring(replay_until), + 'leaseUntil', '0', + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) +redis.call( + 'HDEL', + KEYS[1], + 'renewOperation', + 'renewLeaseUntil', + 'failureOperation', + 'failureDisposition', + 'releaseOperation') +redis.call('PEXPIRE', KEYS[1], tostring(replay_ttl)) +return reply('COMPLETED', stored_attempt, replay_until, payload, digest, operation) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-fail-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-fail-v1.lua new file mode 100644 index 0000000..8842357 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-fail-v1.lua @@ -0,0 +1,143 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_ATTEMPT = 1000000000 +local MAX_RETENTION = 2592000000 +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function reply(status, attempt) + return {status, tostring(attempt), '0', '-', '-', '-'} +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local attempt = integer(ARGV[3], MAX_ATTEMPT) +local disposition = ARGV[4] +local retention = integer(ARGV[5], MAX_RETENTION) +local operation = ARGV[6] +if schema ~= 2 or not token(owner) or attempt == nil or attempt < 1 + or (disposition ~= 'RETRYABLE_NO_EFFECT' and disposition ~= 'ABANDONED_EFFECT_UNKNOWN') + or retention == nil or retention < 1 or not token(operation) then + return reply('INVALID', 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'ownerToken', + 'attempt', + 'failureOperation', + 'failureDisposition', + 'startOperation', + 'stateRevision', + 'updatedAtMillis', + 'responsePayload', + 'releaseOperation', + 'responseDigest', + 'replayUntil', + 'completeOperation', + 'leaseUntil', + 'renewOperation', + 'renewLeaseUntil', + 'fingerprint', + 'claimOperation', + 'responseCodecId', + 'policyRevision', + 'recordTtlMillis') +local stored_attempt = integer(stored[4], MAX_ATTEMPT) +local state_revision = integer(stored[8], MAX_EXACT) +local updated_at = integer(stored[9], MAX_EXACT) +local lease_until = integer(stored[15], MAX_EXACT) +local record_ttl = integer(stored[22], MAX_RETENTION) +if stored[1] ~= ARGV[1] or stored[2] == false or stored[3] == false + or stored_attempt == nil or not token(stored[7]) + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now or lease_until == nil + or stored[10] ~= false or stored[11] ~= false + or stored[12] ~= false or stored[13] ~= false or stored[14] ~= false + or stored[18] == false or string.match(stored[18], '^[0-9a-f]+$') == nil + or #stored[18] ~= 64 or not token(stored[19]) + or not identifier(stored[20]) or not identifier(stored[21]) + or record_ttl == nil or record_ttl < 1 then + return reply('STATE_INCOMPATIBLE', 0) +end +local renew_present = stored[16] ~= false or stored[17] ~= false +if renew_present + and (not token(stored[16]) or integer(stored[17], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0) +end +if stored[3] ~= owner or stored_attempt ~= attempt then + return reply('NOT_OWNER', stored_attempt) +end +if stored[2] == 'FAILED_RETRYABLE' or stored[2] == 'ABANDONED' then + if lease_until ~= 0 or not token(stored[5]) + or (stored[2] == 'FAILED_RETRYABLE' and stored[6] ~= 'RETRYABLE_NO_EFFECT') + or (stored[2] == 'ABANDONED' and stored[6] ~= 'ABANDONED_EFFECT_UNKNOWN') + or renew_present then + return reply('STATE_INCOMPATIBLE', 0) + end + if stored[5] == operation and stored[6] == disposition then + return reply('ALREADY_MARKED_SAME_OPERATION', stored_attempt) + end + return reply('OPERATION_CONFLICT', stored_attempt) +end +if stored[2] ~= 'EXECUTING' then return reply('NOT_IN_PROGRESS', stored_attempt) end +if lease_until < 1 or stored[5] ~= false or stored[6] ~= false then + return reply('STATE_INCOMPATIBLE', 0) +end +local next_state = disposition == 'RETRYABLE_NO_EFFECT' and 'FAILED_RETRYABLE' or 'ABANDONED' +redis.call( + 'HSET', + KEYS[1], + 'state', next_state, + 'failureOperation', operation, + 'failureDisposition', disposition, + 'leaseUntil', '0', + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) +redis.call( + 'HDEL', + KEYS[1], + 'renewOperation', + 'renewLeaseUntil', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'releaseOperation', + 'completeOperation') +redis.call('PEXPIRE', KEYS[1], tostring(retention)) +return reply( + disposition == 'RETRYABLE_NO_EFFECT' and 'MARKED_RETRYABLE' or 'MARKED_ABANDONED', + stored_attempt) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-inspect-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-inspect-v1.lua new file mode 100644 index 0000000..109ec71 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-inspect-v1.lua @@ -0,0 +1,197 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_ATTEMPT = 1000000000 +local MAX_EXACT = 9007199254740991 +local MAX_RESPONSE_BYTES = 10924 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function response_payload(value) + if type(value) ~= 'string' or #value < 1 or #value > MAX_RESPONSE_BYTES then + return false + end + if value == '-' then return true end + return string.match(value, '^[A-Za-z0-9_-]+$') ~= nil and (#value % 4) ~= 1 +end + +local function digest(value) + return type(value) == 'string' + and #value == 64 + and string.match(value, '^[0-9a-f]+$') ~= nil +end + +local function reply(status, attempt, expires_at, payload, digest, operation) + return { + status, + tostring(attempt), + tostring(expires_at), + payload or '-', + digest or '-', + operation or '-' + } +end + +local schema = integer(ARGV[1], 9999) +local fingerprint = ARGV[2] +local owner = ARGV[3] +local operation = ARGV[4] +if schema ~= 2 + or type(fingerprint) ~= 'string' or #fingerprint ~= 64 + or string.match(fingerprint, '^[0-9a-f]+$') == nil + or not token(owner) or not token(operation) then + return reply('INVALID', 0, 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'fingerprint', + 'ownerToken', + 'attempt', + 'claimOperation', + 'startOperation', + 'leaseUntil', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'releaseOperation', + 'failureOperation', + 'failureDisposition', + 'stateRevision', + 'updatedAtMillis', + 'completeOperation', + 'responseCodecId', + 'policyRevision', + 'recordTtlMillis', + 'renewOperation', + 'renewLeaseUntil') +local state = stored[2] +local attempt = integer(stored[5], MAX_ATTEMPT) +local lease_until = integer(stored[8], MAX_EXACT) +local state_revision = integer(stored[15], MAX_EXACT) +local updated_at = integer(stored[16], MAX_EXACT) +local record_ttl = integer(stored[20], 2592000000) +if stored[1] ~= ARGV[1] or state == false or stored[3] == false or not token(stored[4]) + or attempt == nil or not token(stored[6]) or lease_until == nil + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now + or not identifier(stored[18]) or not identifier(stored[19]) + or record_ttl == nil or record_ttl < 1 then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if stored[3] ~= fingerprint then return reply('FINGERPRINT_MISMATCH', attempt, 0) end +local renew_present = stored[21] ~= false or stored[22] ~= false +if renew_present + and (not token(stored[21]) or integer(stored[22], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if state == 'COMPLETED' then + local replay_until = integer(stored[11], MAX_EXACT) + if not response_payload(stored[9]) or not digest(stored[10]) or replay_until == nil + or not token(stored[7]) or not token(stored[17]) + or lease_until ~= 0 or stored[12] ~= false or stored[13] ~= false + or stored[14] ~= false or renew_present then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + if replay_until <= now then return reply('ABSENT', attempt, 0) end + return reply('COMPLETED_REPLAY', attempt, replay_until, stored[9], stored[10]) +end +if state == 'CLAIMED' then + if lease_until < 1 + or stored[7] ~= false + or stored[9] ~= false or stored[10] ~= false or stored[11] ~= false + or stored[13] ~= false or stored[14] ~= false + or stored[12] ~= false or stored[17] ~= false then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + if lease_until <= now then return reply('ABSENT', attempt, 0) end + if stored[4] == owner and stored[6] == operation then + return reply('CLAIMED_SAME_OPERATION', attempt, lease_until) + end + if stored[4] == owner or stored[6] == operation then + return reply('OPERATION_CONFLICT', attempt, lease_until) + end + return reply('IN_PROGRESS_OTHER', attempt, lease_until) +end +if state == 'EXECUTING' then + if lease_until < 1 + or not token(stored[7]) + or stored[9] ~= false or stored[10] ~= false or stored[11] ~= false + or stored[13] ~= false or stored[14] ~= false + or stored[12] ~= false or stored[17] ~= false then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + if lease_until <= now then return reply('ABANDONED', attempt, 0) end + if stored[4] == owner and (stored[6] == operation or stored[7] == operation) then + return reply('EXECUTING_SAME_OPERATION', attempt, lease_until) + end + if stored[4] == owner or stored[6] == operation or stored[7] == operation then + return reply('OPERATION_CONFLICT', attempt, lease_until) + end + return reply('IN_PROGRESS_OTHER', attempt, lease_until) +end +if state == 'FAILED_RETRYABLE' then + if not token(stored[7]) or not token(stored[13]) + or stored[14] ~= 'RETRYABLE_NO_EFFECT' + or stored[9] ~= false or stored[10] ~= false or stored[11] ~= false + or stored[12] ~= false or stored[17] ~= false + or lease_until ~= 0 or renew_present then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + return reply('FAILED_RETRYABLE', attempt, 0) +end +if state == 'ABANDONED' then + if not token(stored[7]) or not token(stored[13]) + or stored[14] ~= 'ABANDONED_EFFECT_UNKNOWN' + or stored[9] ~= false or stored[10] ~= false or stored[11] ~= false + or stored[12] ~= false or stored[17] ~= false + or lease_until ~= 0 or renew_present then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + return reply('ABANDONED', attempt, 0) +end +if state == 'RELEASED' then + if not token(stored[12]) + or stored[9] ~= false or stored[10] ~= false or stored[11] ~= false + or stored[13] ~= false or stored[14] ~= false + or stored[7] ~= false or stored[17] ~= false + or lease_until ~= 0 or renew_present then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + if stored[4] == owner and stored[12] ~= operation then + return reply('OPERATION_CONFLICT', attempt, 0) + end + return reply('ABSENT', attempt, 0) +end +return reply('STATE_INCOMPATIBLE', 0, 0) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-release-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-release-v1.lua new file mode 100644 index 0000000..a1d4a92 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-release-v1.lua @@ -0,0 +1,129 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_ATTEMPT = 1000000000 +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function reply(status, attempt) + return {status, tostring(attempt), '0', '-', '-', '-'} +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local attempt = integer(ARGV[3], MAX_ATTEMPT) +local operation = ARGV[4] +if schema ~= 2 or not token(owner) or attempt == nil or attempt < 1 or not token(operation) then + return reply('INVALID', 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'ownerToken', + 'attempt', + 'releaseOperation', + 'recordTtlMillis', + 'startOperation', + 'stateRevision', + 'updatedAtMillis', + 'responsePayload', + 'failureOperation', + 'responseDigest', + 'replayUntil', + 'completeOperation', + 'leaseUntil', + 'renewOperation', + 'renewLeaseUntil', + 'fingerprint', + 'claimOperation', + 'responseCodecId', + 'policyRevision') +local stored_attempt = integer(stored[4], MAX_ATTEMPT) +local retention = integer(stored[6], 2592000000) +local state_revision = integer(stored[8], MAX_EXACT) +local updated_at = integer(stored[9], MAX_EXACT) +local lease_until = integer(stored[15], MAX_EXACT) +if stored[1] ~= ARGV[1] or stored[2] == false or stored[3] == false + or stored_attempt == nil or retention == nil or lease_until == nil + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now + or stored[10] ~= false or stored[11] ~= false + or stored[12] ~= false or stored[13] ~= false or stored[14] ~= false + or stored[18] == false or string.match(stored[18], '^[0-9a-f]+$') == nil + or #stored[18] ~= 64 or not token(stored[19]) + or not identifier(stored[20]) or not identifier(stored[21]) then + return reply('STATE_INCOMPATIBLE', 0) +end +local renew_present = stored[16] ~= false or stored[17] ~= false +if renew_present + and (not token(stored[16]) or integer(stored[17], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0) +end +if stored[3] ~= owner or stored_attempt ~= attempt then + return reply('NOT_OWNER', stored_attempt) +end +if stored[2] == 'RELEASED' then + if lease_until ~= 0 or not token(stored[5]) or stored[7] ~= false or renew_present then + return reply('STATE_INCOMPATIBLE', 0) + end + if stored[5] == operation then return reply('ALREADY_RELEASED_SAME_OPERATION', stored_attempt) end + return reply('OPERATION_CONFLICT', stored_attempt) +end +if stored[2] == 'EXECUTING' then return reply('EXECUTION_ALREADY_STARTED', stored_attempt) end +if stored[2] ~= 'CLAIMED' then return reply('EXECUTION_ALREADY_STARTED', stored_attempt) end +if lease_until < 1 or stored[5] ~= false or stored[7] ~= false then + return reply('STATE_INCOMPATIBLE', 0) +end +redis.call( + 'HSET', + KEYS[1], + 'state', 'RELEASED', + 'releaseOperation', operation, + 'leaseUntil', '0', + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) +redis.call( + 'HDEL', + KEYS[1], + 'renewOperation', + 'renewLeaseUntil', + 'failureOperation', + 'failureDisposition', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'completeOperation') +redis.call('PEXPIRE', KEYS[1], tostring(retention)) +return reply('RELEASED_BEFORE_EXECUTION', stored_attempt) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-renew-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-renew-v1.lua new file mode 100644 index 0000000..ad3ac50 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-renew-v1.lua @@ -0,0 +1,121 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_ATTEMPT = 1000000000 +local MAX_PROCESSING_TTL = 86400000 +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function reply(status, attempt, expires_at) + return {status, tostring(attempt), tostring(expires_at), '-', '-', '-'} +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local attempt = integer(ARGV[3], MAX_ATTEMPT) +local ttl = integer(ARGV[4], MAX_PROCESSING_TTL) +local operation = ARGV[5] +if schema ~= 2 or not token(owner) or attempt == nil or attempt < 1 + or ttl == nil or ttl < 1 or not token(operation) then + return reply('INVALID', 0, 0) +end +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, 0) end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'ownerToken', + 'attempt', + 'leaseUntil', + 'renewOperation', + 'renewLeaseUntil', + 'recordTtlMillis', + 'stateRevision', + 'updatedAtMillis', + 'responsePayload', + 'failureOperation', + 'releaseOperation', + 'fingerprint', + 'claimOperation', + 'startOperation', + 'responseCodecId', + 'policyRevision') +local stored_attempt = integer(stored[4], MAX_ATTEMPT) +local lease_until = integer(stored[5], MAX_EXACT) +local record_ttl = integer(stored[8], 2592000000) +local state_revision = integer(stored[9], MAX_EXACT) +local updated_at = integer(stored[10], MAX_EXACT) +if stored[1] ~= ARGV[1] or stored[2] == false or stored[3] == false + or stored_attempt == nil or lease_until == nil or record_ttl == nil + or ttl >= record_ttl + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now + or stored[11] ~= false or stored[12] ~= false or stored[13] ~= false + or stored[14] == false or string.match(stored[14], '^[0-9a-f]+$') == nil + or #stored[14] ~= 64 or not token(stored[15]) + or not identifier(stored[17]) or not identifier(stored[18]) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +local renew_present = stored[6] ~= false or stored[7] ~= false +if renew_present + and (not token(stored[6]) or integer(stored[7], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if stored[3] ~= owner or stored_attempt ~= attempt then + return reply('NOT_OWNER', stored_attempt, lease_until) +end +if stored[2] ~= 'CLAIMED' and stored[2] ~= 'EXECUTING' then + return reply('NOT_IN_PROGRESS', stored_attempt, lease_until) +end +if (stored[2] == 'CLAIMED' and stored[16] ~= false) + or (stored[2] == 'EXECUTING' and not token(stored[16])) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if lease_until < 1 then return reply('STATE_INCOMPATIBLE', 0, 0) end +if stored[6] == operation then + local replayed_lease = integer(stored[7], MAX_EXACT) + if replayed_lease == nil then return reply('STATE_INCOMPATIBLE', 0, 0) end + return reply('ALREADY_RENEWED_SAME_OPERATION', stored_attempt, replayed_lease) +end +if lease_until <= now then return reply('NOT_IN_PROGRESS', stored_attempt, lease_until) end +local renewed_until = now + ttl +redis.call( + 'HSET', + KEYS[1], + 'leaseUntil', tostring(renewed_until), + 'renewOperation', operation, + 'renewLeaseUntil', tostring(renewed_until), + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) +redis.call('PEXPIRE', KEYS[1], tostring(record_ttl)) +return reply('RENEWED', stored_attempt, renewed_until) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-start-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-start-v1.lua new file mode 100644 index 0000000..4655c39 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/idempotency-start-v1.lua @@ -0,0 +1,120 @@ +-- Program semantic revision v1; stored application contract schema remains v2. +local MAX_ATTEMPT = 1000000000 +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function identifier(value) + return type(value) == 'string' + and #value >= 1 and #value <= 63 + and string.match(value, '^[a-z][a-z0-9._-]*$') ~= nil +end + +local function reply(status, attempt, expires_at) + return {status, tostring(attempt), tostring(expires_at), '-', '-', '-'} +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local attempt = integer(ARGV[3], MAX_ATTEMPT) +local operation = ARGV[4] +if schema ~= 2 or not token(owner) or attempt == nil or attempt < 1 or not token(operation) then + return reply('INVALID', 0, 0) +end +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, 0) end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'state', + 'ownerToken', + 'attempt', + 'leaseUntil', + 'startOperation', + 'stateRevision', + 'updatedAtMillis', + 'responsePayload', + 'responseDigest', + 'replayUntil', + 'failureOperation', + 'releaseOperation', + 'fingerprint', + 'claimOperation', + 'responseCodecId', + 'policyRevision', + 'recordTtlMillis', + 'renewOperation', + 'renewLeaseUntil') +local stored_attempt = integer(stored[4], MAX_ATTEMPT) +local lease_until = integer(stored[5], MAX_EXACT) +local state_revision = integer(stored[7], MAX_EXACT) +local updated_at = integer(stored[8], MAX_EXACT) +local record_ttl = integer(stored[18], 2592000000) +if stored[1] ~= ARGV[1] or stored[2] == false or stored[3] == false + or stored_attempt == nil or lease_until == nil + or state_revision == nil or state_revision < 1 or state_revision >= MAX_EXACT + or updated_at == nil or updated_at > now + or stored[9] ~= false or stored[10] ~= false or stored[11] ~= false + or stored[12] ~= false or stored[13] ~= false + or stored[14] == false or string.match(stored[14], '^[0-9a-f]+$') == nil + or #stored[14] ~= 64 or not token(stored[15]) + or not identifier(stored[16]) or not identifier(stored[17]) + or record_ttl == nil or record_ttl < 1 then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +local renew_present = stored[19] ~= false or stored[20] ~= false +if renew_present + and (not token(stored[19]) or integer(stored[20], MAX_EXACT) == nil) then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if stored[3] ~= owner or stored_attempt ~= attempt then + return reply('NOT_OWNER', stored_attempt, lease_until) +end +if stored[2] == 'EXECUTING' then + if lease_until < 1 or not token(stored[6]) then + return reply('STATE_INCOMPATIBLE', 0, 0) + end + if lease_until <= now then return reply('NOT_CLAIMED', stored_attempt, lease_until) end + if stored[6] == operation then + return reply('ALREADY_STARTED_SAME_OPERATION', stored_attempt, lease_until) + end + return reply('OPERATION_CONFLICT', stored_attempt, lease_until) +end +if stored[2] ~= 'CLAIMED' then return reply('NOT_CLAIMED', stored_attempt, lease_until) end +if lease_until < 1 or stored[6] ~= false then + return reply('STATE_INCOMPATIBLE', 0, 0) +end +if lease_until <= now then return reply('NOT_CLAIMED', stored_attempt, lease_until) end +redis.call( + 'HSET', + KEYS[1], + 'state', 'EXECUTING', + 'startOperation', operation, + 'stateRevision', tostring(state_revision + 1), + 'updatedAtMillis', tostring(now)) +return reply('STARTED', stored_attempt, lease_until) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/increment-with-initial-ttl-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/increment-with-initial-ttl-v1.lua new file mode 100644 index 0000000..5eab32a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/increment-with-initial-ttl-v1.lua @@ -0,0 +1,186 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end + +if #KEYS ~= 1 or #ARGV ~= 4 then + return invalid('ARITY') +end + +local DIGIT = { + ['0'] = 0, ['1'] = 1, ['2'] = 2, ['3'] = 3, ['4'] = 4, + ['5'] = 5, ['6'] = 6, ['7'] = 7, ['8'] = 8, ['9'] = 9 +} +local CHARACTER = {'0','1','2','3','4','5','6','7','8','9'} + +local function canonical(value) + if type(value) ~= 'string' or #value < 1 or #value > 20 then + return false + end + if value == '0' then + return true + end + local negative = string.sub(value, 1, 1) == '-' + local digits = negative and string.sub(value, 2) or value + if #digits < 1 or string.sub(digits, 1, 1) == '0' then + return false + end + for index = 1, #digits do + if DIGIT[string.sub(digits, index, index)] == nil then + return false + end + end + local limit = negative and '9223372036854775808' or '9223372036854775807' + return #digits < #limit or (#digits == #limit and digits <= limit) +end + +local function parts(value) + if string.sub(value, 1, 1) == '-' then + return true, string.sub(value, 2) + end + return false, value +end + +local function compare_abs(left, right) + if #left ~= #right then + return #left < #right and -1 or 1 + end + if left == right then + return 0 + end + return left < right and -1 or 1 +end + +local function add_abs(left, right) + local result = {} + local carry = 0 + local li = #left + local ri = #right + while li > 0 or ri > 0 or carry > 0 do + local ld = li > 0 and DIGIT[string.sub(left, li, li)] or 0 + local rd = ri > 0 and DIGIT[string.sub(right, ri, ri)] or 0 + local sum = ld + rd + carry + table.insert(result, 1, CHARACTER[(sum % 10) + 1]) + carry = sum >= 10 and 1 or 0 + li = li - 1 + ri = ri - 1 + end + return table.concat(result) +end + +local function subtract_abs(larger, smaller) + local result = {} + local borrow = 0 + local li = #larger + local si = #smaller + while li > 0 do + local ld = DIGIT[string.sub(larger, li, li)] - borrow + local sd = si > 0 and DIGIT[string.sub(smaller, si, si)] or 0 + if ld < sd then + ld = ld + 10 + borrow = 1 + else + borrow = 0 + end + table.insert(result, 1, CHARACTER[(ld - sd) + 1]) + li = li - 1 + si = si - 1 + end + local value = table.concat(result) + value = string.gsub(value, '^0+', '') + return value == '' and '0' or value +end + +local function add_signed(left, right) + local leftNegative, leftAbs = parts(left) + local rightNegative, rightAbs = parts(right) + local negative + local absolute + if leftNegative == rightNegative then + negative = leftNegative + absolute = add_abs(leftAbs, rightAbs) + else + local comparison = compare_abs(leftAbs, rightAbs) + if comparison == 0 then + return '0' + elseif comparison > 0 then + negative = leftNegative + absolute = subtract_abs(leftAbs, rightAbs) + else + negative = rightNegative + absolute = subtract_abs(rightAbs, leftAbs) + end + end + local candidate = negative and ('-' .. absolute) or absolute + return canonical(candidate) and candidate or nil +end + +local function compare_signed(left, right) + if left == right then + return 0 + end + local leftNegative, leftAbs = parts(left) + local rightNegative, rightAbs = parts(right) + if leftNegative ~= rightNegative then + return leftNegative and -1 or 1 + end + local comparison = compare_abs(leftAbs, rightAbs) + return leftNegative and -comparison or comparison +end + +local function positive_ttl(value) + if not string.match(value, '^[1-9][0-9]*$') or #value > 10 then + return false + end + return #value < 10 or value <= '2678400000' +end + +if not canonical(ARGV[1]) or ARGV[1] == '0' + or not canonical(ARGV[2]) or not canonical(ARGV[3]) + or compare_signed(ARGV[2], ARGV[3]) > 0 + or not positive_ttl(ARGV[4]) then + return invalid('INPUT') +end + +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind ~= 'none' and kind ~= 'string' then + return {'V1', 'WRONG_TYPE', '-'} +end + +local current = '0' +local remaining = -2 +if kind == 'string' then + current = redis.call('GET', key) + if not canonical(current) then + return {'V1', 'MALFORMED_VALUE', '-'} + end + remaining = redis.call('PTTL', key) + if remaining <= 0 then + return {'V1', 'MISSING_TTL', current} + end +end + +local updated = add_signed(current, ARGV[1]) +if updated == nil then + return {'V1', 'OVERFLOW', current} +end +if compare_signed(updated, ARGV[2]) < 0 or compare_signed(updated, ARGV[3]) > 0 then + return {'V1', 'LIMIT_EXCEEDED', current} +end + +if not redis.acl_check_cmd('type', key) + or (kind == 'string' and + (not redis.acl_check_cmd('get', key) + or not redis.acl_check_cmd('pttl', key) + or not redis.acl_check_cmd('incrby', key, ARGV[1]))) + or (kind == 'none' and + not redis.acl_check_cmd('set', key, updated, 'PX', ARGV[4])) then + return invalid('ACL') +end + +if kind == 'none' then + redis.call('SET', key, updated, 'PX', ARGV[4]) +else + redis.call('INCRBY', key, ARGV[1]) +end +return {'V1', 'UPDATED', updated} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-acquire-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-acquire-v1.lua new file mode 100644 index 0000000..4f63dde --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-acquire-v1.lua @@ -0,0 +1,112 @@ +-- Efficiency-only lease. This program does not issue a fencing token. +local MAX_EXACT = 9007199254740991 +local MAX_TTL = 86400000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function reply(status, remaining, now, expires_at, revision, operation) + return { + status, + tostring(remaining), + tostring(now), + tostring(expires_at), + tostring(revision), + operation or '-' + } +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local operation = ARGV[3] +local ttl = integer(ARGV[4], MAX_TTL) +if schema ~= 1 or not token(owner) or not token(operation) or ttl == nil or ttl < 1 then + return reply('INVALID', 0, 0, 0, 0) +end + +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +if now > MAX_EXACT - ttl then return reply('INVALID', 0, 0, 0, 0) end +local expires_at = now + ttl +local state_type = key_type(KEYS[1]) +if state_type == 'none' then + redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'ownerToken', owner, + 'operationId', operation, + 'stateRevision', '1', + 'updatedAtMillis', tostring(now)) + redis.call('PEXPIRE', KEYS[1], tostring(ttl)) + return reply('ACQUIRED', ttl, now, expires_at, 1, operation) +end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'ownerToken', + 'operationId', + 'stateRevision', + 'updatedAtMillis') +local revision = integer(stored[4], MAX_EXACT) +local updated_at = integer(stored[5], MAX_EXACT) +local remaining = redis.call('PTTL', KEYS[1]) +if stored[1] ~= ARGV[1] or not token(stored[2]) or not token(stored[3]) + or revision == nil or revision < 1 or revision >= MAX_EXACT + or updated_at == nil or updated_at > now or remaining == -1 then + return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) +end +if remaining < 1 then + redis.call('DEL', KEYS[1]) + redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'ownerToken', owner, + 'operationId', operation, + 'stateRevision', tostring(revision + 1), + 'updatedAtMillis', tostring(now)) + redis.call('PEXPIRE', KEYS[1], tostring(ttl)) + return reply('ACQUIRED', ttl, now, expires_at, revision + 1, operation) +end +local stored_expires_at = now + remaining +if stored[2] == owner and stored[3] == operation then + return reply( + 'REPLAYED_SAME_OPERATION', + remaining, + now, + stored_expires_at, + revision, + operation) +end +if (stored[2] == owner) ~= (stored[3] == operation) then + return reply( + 'OWNER_OPERATION_CONFLICT', + remaining, + now, + stored_expires_at, + revision) +end +return reply('CONTENDED', remaining, now, stored_expires_at, revision) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-inspect-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-inspect-v1.lua new file mode 100644 index 0000000..c3b96bf --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-inspect-v1.lua @@ -0,0 +1,72 @@ +-- Read-only inspection for an efficiency-only lease. +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function reply(status, remaining, now, expires_at, revision, operation) + return { + status, + tostring(remaining), + tostring(now), + tostring(expires_at), + tostring(revision), + operation or '-' + } +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local operation = ARGV[3] +if schema ~= 1 or not token(owner) or not token(operation) then + return reply('INVALID', 0, 0, 0, 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0, now, 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'ownerToken', + 'operationId', + 'stateRevision', + 'updatedAtMillis') +local revision = integer(stored[4], MAX_EXACT) +local updated_at = integer(stored[5], MAX_EXACT) +local remaining = redis.call('PTTL', KEYS[1]) +if stored[1] ~= ARGV[1] or not token(stored[2]) or not token(stored[3]) + or revision == nil or revision < 1 + or updated_at == nil or updated_at > now or remaining == -1 then + return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) +end +if remaining < 1 then return reply('ABSENT', 0, now, 0, revision) end +local expires_at = now + remaining +if stored[2] == owner and stored[3] == operation then + return reply('OWNED', remaining, now, expires_at, revision, operation) +end +if (stored[2] == owner) ~= (stored[3] == operation) then + return reply('OWNER_OPERATION_CONFLICT', remaining, now, expires_at, revision) +end +return reply('NOT_OWNER', remaining, now, expires_at, revision) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-release-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-release-v1.lua new file mode 100644 index 0000000..0741952 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-release-v1.lua @@ -0,0 +1,72 @@ +-- Owner-safe release for an efficiency-only lease. +local MAX_EXACT = 9007199254740991 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function reply(status, remaining, now, expires_at, revision, operation) + return { + status, + tostring(remaining), + tostring(now), + tostring(expires_at), + tostring(revision), + operation or '-' + } +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local operation = ARGV[3] +if schema ~= 1 or not token(owner) or not token(operation) then + return reply('INVALID', 0, 0, 0, 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ALREADY_ABSENT', 0, now, 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'ownerToken', + 'operationId', + 'stateRevision', + 'updatedAtMillis') +local revision = integer(stored[4], MAX_EXACT) +local updated_at = integer(stored[5], MAX_EXACT) +local remaining = redis.call('PTTL', KEYS[1]) +if stored[1] ~= ARGV[1] or not token(stored[2]) or not token(stored[3]) + or revision == nil or revision < 1 + or updated_at == nil or updated_at > now or remaining == -1 then + return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) +end +if remaining < 1 then return reply('ALREADY_ABSENT', 0, now, 0, revision) end +if stored[2] ~= owner or stored[3] ~= operation then + if (stored[2] == owner) ~= (stored[3] == operation) then + return reply('OWNER_OPERATION_CONFLICT', remaining, now, now + remaining, revision) + end + return reply('NOT_OWNER', remaining, now, now + remaining, revision) +end +redis.call('DEL', KEYS[1]) +return reply('RELEASED', 0, now, 0, revision, operation) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-renew-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-renew-v1.lua new file mode 100644 index 0000000..3d83637 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/lease-renew-v1.lua @@ -0,0 +1,81 @@ +-- Owner-safe renew for an efficiency-only lease. +local MAX_EXACT = 9007199254740991 +local MAX_TTL = 86400000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then return nil end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then return nil end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function token(value) + return type(value) == 'string' + and #value >= 16 and #value <= 128 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then return result['ok'] end + return result +end + +local function reply(status, remaining, now, expires_at, revision, operation) + return { + status, + tostring(remaining), + tostring(now), + tostring(expires_at), + tostring(revision), + operation or '-' + } +end + +local schema = integer(ARGV[1], 9999) +local owner = ARGV[2] +local operation = ARGV[3] +local ttl = integer(ARGV[4], MAX_TTL) +if schema ~= 1 or not token(owner) or not token(operation) or ttl == nil or ttl < 1 then + return reply('INVALID', 0, 0, 0, 0) +end +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +if now > MAX_EXACT - ttl then return reply('INVALID', 0, 0, 0, 0) end +local state_type = key_type(KEYS[1]) +if state_type == 'none' then return reply('ABSENT', 0, now, 0, 0) end +if state_type ~= 'hash' then return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) end +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'ownerToken', + 'operationId', + 'stateRevision', + 'updatedAtMillis') +local revision = integer(stored[4], MAX_EXACT) +local updated_at = integer(stored[5], MAX_EXACT) +local remaining = redis.call('PTTL', KEYS[1]) +if stored[1] ~= ARGV[1] or not token(stored[2]) or not token(stored[3]) + or revision == nil or revision < 1 or revision >= MAX_EXACT + or updated_at == nil or updated_at > now or remaining == -1 then + return reply('STATE_INCOMPATIBLE', 0, now, 0, 0) +end +if remaining < 1 then return reply('ABSENT', 0, now, 0, revision) end +if stored[2] ~= owner or stored[3] ~= operation then + if (stored[2] == owner) ~= (stored[3] == operation) then + return reply('OWNER_OPERATION_CONFLICT', remaining, now, now + remaining, revision) + end + return reply('NOT_OWNER', remaining, now, now + remaining, revision) +end +local expires_at = now + ttl +redis.call( + 'HSET', + KEYS[1], + 'stateRevision', tostring(revision + 1), + 'updatedAtMillis', tostring(now)) +redis.call('PEXPIRE', KEYS[1], tostring(ttl)) +return reply('RENEWED', ttl, now, expires_at, revision + 1, operation) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v1.lua new file mode 100644 index 0000000..5442cef --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v1.lua @@ -0,0 +1,146 @@ +local MAX_EXACT = 9007199254740991 +local MAX_LIMIT = 1000000000 +local MAX_WINDOW_MS = 86400000 +local MAX_GRACE_MS = 86400000 +local MAX_REGRESSION_MS = 3600000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then + return nil + end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then + return nil + end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function number(value) + return string.format('%.0f', value) +end + +local function reply(status, server_now, effective_now, limit, remaining, retry_after, reset_at) + return { + status, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + } +end + +local time = redis.call('TIME') +local server_now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local schema = integer(ARGV[1], 9999) +local revision = ARGV[2] +local limit = integer(ARGV[3], MAX_LIMIT) +local cost = integer(ARGV[4], MAX_LIMIT) +local window_ms = integer(ARGV[5], MAX_WINDOW_MS) +local grace_ms = integer(ARGV[6], MAX_GRACE_MS) +local maximum_regression_ms = integer(ARGV[7], MAX_REGRESSION_MS) +if schema == nil or schema < 1 + or revision == nil or #revision < 1 or #revision > 64 + or limit == nil or limit < 1 + or cost == nil or cost < 1 or cost > limit + or window_ms == nil or window_ms < 1 + or grace_ms == nil + or maximum_regression_ms == nil + or server_now > MAX_EXACT then + return reply('INVALID', server_now, server_now, 0, 0, 0, 0) +end + +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', server_now, server_now, limit, 0, 0, 0) +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'algorithm', + 'policyRevision', + 'lastObservedMillis', + 'windowId', + 'consumed') +local exists = state_type == 'hash' +local last_observed = server_now +local stored_window_id = -1 +local consumed = 0 +if exists then + if stored[1] ~= ARGV[1] + or stored[2] ~= 'fixed-window' + or stored[3] ~= revision then + return reply('STATE_INCOMPATIBLE', server_now, server_now, limit, 0, 0, 0) + end + last_observed = integer(stored[4], MAX_EXACT) + stored_window_id = integer(stored[5], MAX_EXACT) + consumed = integer(stored[6], MAX_LIMIT) + if last_observed == nil or stored_window_id == nil or consumed == nil or consumed > limit then + return reply('STATE_INCOMPATIBLE', server_now, server_now, limit, 0, 0, 0) + end +end + +if server_now < last_observed and last_observed - server_now > maximum_regression_ms then + return reply('CLOCK_UNSAFE', server_now, last_observed, limit, math.max(0, limit - consumed), 0, 0) +end +local effective_now = math.max(server_now, last_observed) +local window_id = math.floor(effective_now / window_ms) +if exists and window_id < stored_window_id then + return reply('STATE_INCOMPATIBLE', server_now, effective_now, limit, 0, 0, 0) +end +if not exists or window_id > stored_window_id then + consumed = 0 +end + +local allowed = consumed + cost <= limit +if allowed then + consumed = consumed + cost +end +local remaining = math.max(0, limit - consumed) +local window_end = (window_id + 1) * window_ms +if window_end > MAX_EXACT then + return reply('INVALID', server_now, effective_now, 0, 0, 0, 0) +end +local retry_after = 0 +if not allowed then + retry_after = math.max(1, window_end - effective_now) +end +local ttl = window_end - effective_now + grace_ms +if ttl < 1 or ttl > MAX_WINDOW_MS + MAX_GRACE_MS then + return reply('INVALID', server_now, effective_now, 0, 0, 0, 0) +end + +redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'algorithm', 'fixed-window', + 'policyRevision', revision, + 'lastObservedMillis', number(effective_now), + 'windowId', number(window_id), + 'consumed', number(consumed)) +redis.call('PEXPIRE', KEYS[1], number(ttl)) + +return reply( + allowed and 'ALLOWED' or 'DENIED', + server_now, + effective_now, + limit, + remaining, + retry_after, + window_end) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v2.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v2.lua new file mode 100644 index 0000000..78ea13d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-fixed-window-v2.lua @@ -0,0 +1,334 @@ +local MAX_EXACT = 9007199254740991 +local MAX_LIMIT = 1000000000 +local MAX_WINDOW_MS = 86400000 +local MAX_GRACE_MS = 86400000 +local MAX_REGRESSION_MS = 3600000 +local MAXIMUM_EVALUATION_ID_BYTES = 71 +local MAXIMUM_DEDUP_ENTRIES = 1024 +local MAXIMUM_DEDUP_TTL_MS = 300000 +local MAXIMUM_DEDUP_DATA_BYTES = 262144 +local MAXIMUM_DEDUP_DECISION_BYTES = 128 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then + return nil + end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then + return nil + end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function number(value) + return string.format('%.0f', value) +end + +local function reply( + status, decision, server_now, effective_now, limit, remaining, retry_after, reset_at) + return { + status, + decision, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + } +end + +local function valid_evaluation_id(value) + if value == '-' then + return true + end + if type(value) ~= 'string' or #value > MAXIMUM_EVALUATION_ID_BYTES then + return false + end + local separator = string.find(value, ':', 1, true) + if separator == nil or string.sub(value, 1, 2) ~= 'ev' then + return false + end + local version = string.sub(value, 3, separator - 1) + local token = string.sub(value, separator + 1) + return #version >= 1 + and #version <= 4 + and string.match(version, '^[1-9][0-9]*$') ~= nil + and #token >= 22 + and #token <= 64 + and string.match(token, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function parse_replay(encoded, expected_limit) + if type(encoded) ~= 'string' or #encoded > MAXIMUM_DEDUP_DECISION_BYTES then + return nil + end + local fields = {} + for field in string.gmatch(encoded, '([^|]+)') do + table.insert(fields, field) + end + if #fields ~= 7 or (fields[1] ~= 'ALLOWED' and fields[1] ~= 'DENIED') then + return nil + end + local server_now = integer(fields[2], MAX_EXACT) + local effective_now = integer(fields[3], MAX_EXACT) + local limit = integer(fields[4], MAX_LIMIT) + local remaining = integer(fields[5], MAX_LIMIT) + local retry_after = integer(fields[6], MAX_EXACT) + local reset_at = integer(fields[7], MAX_EXACT) + if server_now == nil + or effective_now == nil + or limit ~= expected_limit + or remaining == nil or remaining > limit + or retry_after == nil + or reset_at == nil + or effective_now < server_now + or reset_at < effective_now + or (fields[1] == 'ALLOWED' and retry_after ~= 0) + or (fields[1] == 'DENIED' and retry_after < 1) then + return nil + end + return { + fields[1], server_now, effective_now, limit, remaining, retry_after, reset_at + } +end + +local function encoded_decision( + decision, server_now, effective_now, limit, remaining, retry_after, reset_at) + local encoded = table.concat( + { + decision, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + }, + '|') + if #encoded > MAXIMUM_DEDUP_DECISION_BYTES then + return nil + end + return encoded +end + +local time = redis.call('TIME') +local server_now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local schema = integer(ARGV[1], 9999) +local revision = ARGV[2] +local limit = integer(ARGV[3], MAX_LIMIT) +local cost = integer(ARGV[4], MAX_LIMIT) +local window_ms = integer(ARGV[5], MAX_WINDOW_MS) +local grace_ms = integer(ARGV[6], MAX_GRACE_MS) +local maximum_regression_ms = integer(ARGV[7], MAX_REGRESSION_MS) +local evaluation_id = ARGV[8] +local dedup_ttl_ms = integer(ARGV[9], MAXIMUM_DEDUP_TTL_MS) +local maximum_dedup_entries = integer(ARGV[10], MAXIMUM_DEDUP_ENTRIES) +local maximum_dedup_bytes = integer(ARGV[11], MAXIMUM_DEDUP_DATA_BYTES) +local dedup_enabled = dedup_ttl_ms ~= nil and dedup_ttl_ms > 0 +local dedup_shape_valid = dedup_enabled + and maximum_dedup_entries ~= nil and maximum_dedup_entries > 0 + and maximum_dedup_bytes ~= nil + and maximum_dedup_entries + * (MAXIMUM_EVALUATION_ID_BYTES + MAXIMUM_DEDUP_DECISION_BYTES) + <= maximum_dedup_bytes +local dedup_disabled = dedup_ttl_ms == 0 + and maximum_dedup_entries == 0 + and maximum_dedup_bytes == 0 +if schema ~= 2 + or revision == nil or #revision < 1 or #revision > 64 + or limit == nil or limit < 1 + or cost == nil or cost < 1 or cost > limit + or window_ms == nil or window_ms < 1 + or grace_ms == nil + or maximum_regression_ms == nil + or not valid_evaluation_id(evaluation_id) + or (not dedup_shape_valid and not dedup_disabled) + or (evaluation_id ~= '-' and not dedup_shape_valid) + or server_now > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, server_now, 0, 0, 0, 0) +end + +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) +end +if state_type == 'hash' then + local identity = redis.call('HMGET', KEYS[1], 'schema', 'algorithm', 'policyRevision') + if identity[1] ~= ARGV[1] + or identity[2] ~= 'fixed-window' + or identity[3] ~= revision then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end +end +if evaluation_id ~= '-' then + local dedup_type = key_type(KEYS[2]) + local order_type = key_type(KEYS[3]) + if (dedup_type ~= 'none' and dedup_type ~= 'hash') + or (order_type ~= 'none' and order_type ~= 'zset') + or (dedup_type == 'none') ~= (order_type == 'none') then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + if dedup_type ~= 'none' then + local decisions = redis.call('HLEN', KEYS[2]) + local ordered = redis.call('ZCARD', KEYS[3]) + if decisions ~= ordered or decisions > maximum_dedup_entries then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + local replay_encoded = redis.call('HGET', KEYS[2], evaluation_id) + if replay_encoded ~= false then + local replay_score = redis.call('ZSCORE', KEYS[3], evaluation_id) + local replay_timestamp = integer(replay_score, MAX_EXACT) + if replay_timestamp == nil then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + if replay_timestamp <= server_now - dedup_ttl_ms then + redis.call('HDEL', KEYS[2], evaluation_id) + redis.call('ZREM', KEYS[3], evaluation_id) + else + local replayed = parse_replay(replay_encoded, limit) + if replayed == nil then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + return reply( + 'DEDUP_REPLAY', + replayed[1], + replayed[2], + replayed[3], + replayed[4], + replayed[5], + replayed[6], + replayed[7]) + end + end + end +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'algorithm', + 'policyRevision', + 'lastObservedMillis', + 'windowId', + 'consumed') +local exists = state_type == 'hash' +local last_observed = server_now +local stored_window_id = -1 +local consumed = 0 +if exists then + if stored[1] ~= ARGV[1] + or stored[2] ~= 'fixed-window' + or stored[3] ~= revision then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + last_observed = integer(stored[4], MAX_EXACT) + stored_window_id = integer(stored[5], MAX_EXACT) + consumed = integer(stored[6], MAX_LIMIT) + if last_observed == nil or stored_window_id == nil or consumed == nil or consumed > limit then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end +end + +if server_now < last_observed and last_observed - server_now > maximum_regression_ms then + return reply( + 'CLOCK_UNSAFE', + 'NONE', + server_now, + last_observed, + limit, + math.max(0, limit - consumed), + 0, + 0) +end +local effective_now = math.max(server_now, last_observed) +local window_id = math.floor(effective_now / window_ms) +if exists and window_id < stored_window_id then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, effective_now, limit, 0, 0, 0) +end +if not exists or window_id > stored_window_id then + consumed = 0 +end + +local allowed = consumed + cost <= limit +if allowed then + consumed = consumed + cost +end +local remaining = math.max(0, limit - consumed) +local window_end = (window_id + 1) * window_ms +if window_end > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end +local retry_after = 0 +if not allowed then + retry_after = math.max(1, window_end - effective_now) +end +local ttl = window_end - effective_now + grace_ms +if ttl < 1 or ttl > MAX_WINDOW_MS + MAX_GRACE_MS then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end +local decision = allowed and 'ALLOWED' or 'DENIED' +local encoded = encoded_decision( + decision, server_now, effective_now, limit, remaining, retry_after, window_end) +if encoded == nil then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end + +redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'algorithm', 'fixed-window', + 'policyRevision', revision, + 'lastObservedMillis', number(effective_now), + 'windowId', number(window_id), + 'consumed', number(consumed)) +redis.call('PEXPIRE', KEYS[1], number(ttl)) + +if evaluation_id ~= '-' then + local expired = redis.call( + 'ZRANGEBYSCORE', + KEYS[3], + '-inf', + number(server_now - dedup_ttl_ms), + 'LIMIT', + 0, + maximum_dedup_entries) + for _, expired_id in ipairs(expired) do + redis.call('HDEL', KEYS[2], expired_id) + redis.call('ZREM', KEYS[3], expired_id) + end + if redis.call('ZCARD', KEYS[3]) >= maximum_dedup_entries then + local evicted = redis.call('ZPOPMIN', KEYS[3], 1) + if #evicted >= 1 then + redis.call('HDEL', KEYS[2], evicted[1]) + end + end + redis.call('HSET', KEYS[2], evaluation_id, encoded) + redis.call('ZADD', KEYS[3], number(server_now), evaluation_id) + redis.call('PEXPIRE', KEYS[2], number(dedup_ttl_ms)) + redis.call('PEXPIRE', KEYS[3], number(dedup_ttl_ms)) +end + +return reply( + decision, + decision, + server_now, + effective_now, + limit, + remaining, + retry_after, + window_end) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v1.lua new file mode 100644 index 0000000..0fff20f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v1.lua @@ -0,0 +1,203 @@ +local MAX_EXACT = 9007199254740991 +local MAX_LIMIT = 1000000000 +local MAX_WINDOW_MS = 86400000 +local MAX_GRACE_MS = 86400000 +local MAX_REGRESSION_MS = 3600000 +local SCALE = 1000000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then + return nil + end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then + return nil + end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function ceiling_divide(numerator, denominator) + local quotient = math.floor(numerator / denominator) + if numerator % denominator == 0 then + return quotient + end + return quotient + 1 +end + +local function number(value) + return string.format('%.0f', value) +end + +local function reply(status, server_now, effective_now, limit, remaining, retry_after, reset_at) + return { + status, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + } +end + +local time = redis.call('TIME') +local server_now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local schema = integer(ARGV[1], 9999) +local revision = ARGV[2] +local limit = integer(ARGV[3], MAX_LIMIT) +local cost = integer(ARGV[4], MAX_LIMIT) +local window_ms = integer(ARGV[5], MAX_WINDOW_MS) +local grace_ms = integer(ARGV[6], MAX_GRACE_MS) +local maximum_regression_ms = integer(ARGV[7], MAX_REGRESSION_MS) +if schema == nil or schema < 1 + or revision == nil or #revision < 1 or #revision > 64 + or limit == nil or limit < 1 or limit * SCALE > MAX_EXACT + or cost == nil or cost < 1 or cost > limit + or window_ms == nil or window_ms < 1 or window_ms * SCALE > MAX_EXACT + or grace_ms == nil + or maximum_regression_ms == nil + or server_now > MAX_EXACT then + return reply('INVALID', server_now, server_now, 0, 0, 0, 0) +end + +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', server_now, server_now, limit, 0, 0, 0) +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'algorithm', + 'policyRevision', + 'lastObservedMillis', + 'previousWindowId', + 'previousCount', + 'currentWindowId', + 'currentCount') +local exists = state_type == 'hash' +local last_observed = server_now +local previous_window_id = -1 +local previous_count = 0 +local current_window_id = -1 +local current_count = 0 +if exists then + if stored[1] ~= ARGV[1] + or stored[2] ~= 'sliding-window-counter' + or stored[3] ~= revision then + return reply('STATE_INCOMPATIBLE', server_now, server_now, limit, 0, 0, 0) + end + last_observed = integer(stored[4], MAX_EXACT) + previous_window_id = integer(stored[5], MAX_EXACT) + previous_count = integer(stored[6], MAX_LIMIT) + current_window_id = integer(stored[7], MAX_EXACT) + current_count = integer(stored[8], MAX_LIMIT) + if last_observed == nil + or previous_window_id == nil + or previous_count == nil + or current_window_id == nil + or current_count == nil + or previous_count > limit + or current_count > limit + or previous_window_id + 1 ~= current_window_id then + return reply('STATE_INCOMPATIBLE', server_now, server_now, limit, 0, 0, 0) + end +end + +if server_now < last_observed and last_observed - server_now > maximum_regression_ms then + return reply('CLOCK_UNSAFE', server_now, last_observed, limit, 0, 0, 0) +end +local effective_now = math.max(server_now, last_observed) +local window_id = math.floor(effective_now / window_ms) +if not exists then + previous_window_id = window_id - 1 + current_window_id = window_id +elseif window_id < current_window_id then + return reply('STATE_INCOMPATIBLE', server_now, effective_now, limit, 0, 0, 0) +elseif window_id == current_window_id + 1 then + previous_window_id = current_window_id + previous_count = current_count + current_window_id = window_id + current_count = 0 +elseif window_id > current_window_id + 1 then + previous_window_id = window_id - 1 + previous_count = 0 + current_window_id = window_id + current_count = 0 +end + +local window_start = window_id * window_ms +local elapsed = effective_now - window_start +local remaining_window = window_ms - elapsed +local previous_weight = ceiling_divide(remaining_window * SCALE, window_ms) +local weighted = current_count * SCALE + previous_count * previous_weight +local cost_scaled = cost * SCALE +local limit_scaled = limit * SCALE +if weighted > MAX_EXACT or cost_scaled > MAX_EXACT or weighted + cost_scaled > MAX_EXACT then + return reply('INVALID', server_now, effective_now, 0, 0, 0, 0) +end +local allowed = weighted + cost_scaled <= limit_scaled +if allowed then + current_count = current_count + cost + weighted = weighted + cost_scaled +end +local remaining = math.max(0, math.floor((limit_scaled - weighted) / SCALE)) +local reset_at = (window_id + 2) * window_ms +if reset_at > MAX_EXACT then + return reply('INVALID', server_now, effective_now, 0, 0, 0, 0) +end +local retry_after = 0 +if not allowed then + local current_base = (current_count + cost) * SCALE + if current_base <= limit_scaled and previous_count > 0 then + local maximum_previous_weight = math.floor((limit_scaled - current_base) / previous_count) + local maximum_remaining = math.floor(maximum_previous_weight * window_ms / SCALE) + retry_after = math.max(1, remaining_window - maximum_remaining) + elseif current_count + cost <= limit then + retry_after = math.max(1, remaining_window) + else + local maximum_previous_weight = math.floor((limit - cost) * SCALE / current_count) + local maximum_remaining = math.floor(maximum_previous_weight * window_ms / SCALE) + local elapsed_after_rollover = window_ms - maximum_remaining + retry_after = math.max(1, remaining_window + elapsed_after_rollover) + end +end +local ttl = 2 * window_ms + grace_ms +if ttl < 1 or ttl > 2 * MAX_WINDOW_MS + MAX_GRACE_MS then + return reply('INVALID', server_now, effective_now, 0, 0, 0, 0) +end + +redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'algorithm', 'sliding-window-counter', + 'policyRevision', revision, + 'lastObservedMillis', number(effective_now), + 'previousWindowId', number(previous_window_id), + 'previousCount', number(previous_count), + 'currentWindowId', number(current_window_id), + 'currentCount', number(current_count)) +redis.call('PEXPIRE', KEYS[1], number(ttl)) + +return reply( + allowed and 'ALLOWED' or 'DENIED', + server_now, + effective_now, + limit, + remaining, + retry_after, + reset_at) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v2.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v2.lua new file mode 100644 index 0000000..4fdc12e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-sliding-counter-v2.lua @@ -0,0 +1,376 @@ +local MAX_EXACT = 9007199254740991 +local MAX_LIMIT = 1000000000 +local MAX_WINDOW_MS = 86400000 +local MAX_GRACE_MS = 86400000 +local MAX_REGRESSION_MS = 3600000 +local MAXIMUM_EVALUATION_ID_BYTES = 71 +local MAXIMUM_DEDUP_ENTRIES = 1024 +local MAXIMUM_DEDUP_TTL_MS = 300000 +local MAXIMUM_DEDUP_DATA_BYTES = 262144 +local MAXIMUM_DEDUP_DECISION_BYTES = 128 +local SCALE = 1000000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then + return nil + end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then + return nil + end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function ceiling_divide(numerator, denominator) + local quotient = math.floor(numerator / denominator) + if numerator % denominator == 0 then + return quotient + end + return quotient + 1 +end + +local function number(value) + return string.format('%.0f', value) +end + +local function reply( + status, decision, server_now, effective_now, limit, remaining, retry_after, reset_at) + return { + status, + decision, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + } +end + +local function valid_evaluation_id(value) + if value == '-' then + return true + end + if type(value) ~= 'string' or #value > MAXIMUM_EVALUATION_ID_BYTES then + return false + end + local separator = string.find(value, ':', 1, true) + if separator == nil or string.sub(value, 1, 2) ~= 'ev' then + return false + end + local version = string.sub(value, 3, separator - 1) + local token = string.sub(value, separator + 1) + return #version >= 1 + and #version <= 4 + and string.match(version, '^[1-9][0-9]*$') ~= nil + and #token >= 22 + and #token <= 64 + and string.match(token, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function parse_replay(encoded, expected_limit) + if type(encoded) ~= 'string' or #encoded > MAXIMUM_DEDUP_DECISION_BYTES then + return nil + end + local fields = {} + for field in string.gmatch(encoded, '([^|]+)') do + table.insert(fields, field) + end + if #fields ~= 7 or (fields[1] ~= 'ALLOWED' and fields[1] ~= 'DENIED') then + return nil + end + local server_now = integer(fields[2], MAX_EXACT) + local effective_now = integer(fields[3], MAX_EXACT) + local limit = integer(fields[4], MAX_LIMIT) + local remaining = integer(fields[5], MAX_LIMIT) + local retry_after = integer(fields[6], MAX_EXACT) + local reset_at = integer(fields[7], MAX_EXACT) + if server_now == nil + or effective_now == nil + or limit ~= expected_limit + or remaining == nil or remaining > limit + or retry_after == nil + or reset_at == nil + or effective_now < server_now + or reset_at < effective_now + or (fields[1] == 'ALLOWED' and retry_after ~= 0) + or (fields[1] == 'DENIED' and retry_after < 1) then + return nil + end + return { + fields[1], server_now, effective_now, limit, remaining, retry_after, reset_at + } +end + +local function encoded_decision( + decision, server_now, effective_now, limit, remaining, retry_after, reset_at) + local encoded = table.concat( + { + decision, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + }, + '|') + if #encoded > MAXIMUM_DEDUP_DECISION_BYTES then + return nil + end + return encoded +end + +local time = redis.call('TIME') +local server_now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local schema = integer(ARGV[1], 9999) +local revision = ARGV[2] +local limit = integer(ARGV[3], MAX_LIMIT) +local cost = integer(ARGV[4], MAX_LIMIT) +local window_ms = integer(ARGV[5], MAX_WINDOW_MS) +local grace_ms = integer(ARGV[6], MAX_GRACE_MS) +local maximum_regression_ms = integer(ARGV[7], MAX_REGRESSION_MS) +local evaluation_id = ARGV[8] +local dedup_ttl_ms = integer(ARGV[9], MAXIMUM_DEDUP_TTL_MS) +local maximum_dedup_entries = integer(ARGV[10], MAXIMUM_DEDUP_ENTRIES) +local maximum_dedup_bytes = integer(ARGV[11], MAXIMUM_DEDUP_DATA_BYTES) +local dedup_enabled = dedup_ttl_ms ~= nil and dedup_ttl_ms > 0 +local dedup_shape_valid = dedup_enabled + and maximum_dedup_entries ~= nil and maximum_dedup_entries > 0 + and maximum_dedup_bytes ~= nil + and maximum_dedup_entries + * (MAXIMUM_EVALUATION_ID_BYTES + MAXIMUM_DEDUP_DECISION_BYTES) + <= maximum_dedup_bytes +local dedup_disabled = dedup_ttl_ms == 0 + and maximum_dedup_entries == 0 + and maximum_dedup_bytes == 0 +if schema ~= 2 + or revision == nil or #revision < 1 or #revision > 64 + or limit == nil or limit < 1 or limit * SCALE > MAX_EXACT + or cost == nil or cost < 1 or cost > limit + or window_ms == nil or window_ms < 1 or window_ms * SCALE > MAX_EXACT + or grace_ms == nil + or maximum_regression_ms == nil + or not valid_evaluation_id(evaluation_id) + or (not dedup_shape_valid and not dedup_disabled) + or (evaluation_id ~= '-' and not dedup_shape_valid) + or server_now > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, server_now, 0, 0, 0, 0) +end + +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'algorithm', + 'policyRevision', + 'lastObservedMillis', + 'previousWindowId', + 'previousCount', + 'currentWindowId', + 'currentCount') +local exists = state_type == 'hash' +local last_observed = server_now +local previous_window_id = -1 +local previous_count = 0 +local current_window_id = -1 +local current_count = 0 +if exists then + if stored[1] ~= ARGV[1] + or stored[2] ~= 'sliding-window-counter' + or stored[3] ~= revision then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + last_observed = integer(stored[4], MAX_EXACT) + previous_window_id = integer(stored[5], MAX_EXACT) + previous_count = integer(stored[6], MAX_LIMIT) + current_window_id = integer(stored[7], MAX_EXACT) + current_count = integer(stored[8], MAX_LIMIT) + if last_observed == nil + or previous_window_id == nil + or previous_count == nil + or current_window_id == nil + or current_count == nil + or previous_count > limit + or current_count > limit + or previous_window_id + 1 ~= current_window_id then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end +end + +if evaluation_id ~= '-' then + local dedup_type = key_type(KEYS[2]) + local order_type = key_type(KEYS[3]) + if (dedup_type ~= 'none' and dedup_type ~= 'hash') + or (order_type ~= 'none' and order_type ~= 'zset') + or (dedup_type == 'none') ~= (order_type == 'none') then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + if dedup_type ~= 'none' then + local decisions = redis.call('HLEN', KEYS[2]) + local ordered = redis.call('ZCARD', KEYS[3]) + if decisions ~= ordered or decisions > maximum_dedup_entries then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + local replay_encoded = redis.call('HGET', KEYS[2], evaluation_id) + if replay_encoded ~= false then + local replay_score = redis.call('ZSCORE', KEYS[3], evaluation_id) + local replay_timestamp = integer(replay_score, MAX_EXACT) + if replay_timestamp == nil then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + if replay_timestamp <= server_now - dedup_ttl_ms then + redis.call('HDEL', KEYS[2], evaluation_id) + redis.call('ZREM', KEYS[3], evaluation_id) + else + local replayed = parse_replay(replay_encoded, limit) + if replayed == nil then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, limit, 0, 0, 0) + end + return reply( + 'DEDUP_REPLAY', + replayed[1], + replayed[2], + replayed[3], + replayed[4], + replayed[5], + replayed[6], + replayed[7]) + end + end + end +end + +if server_now < last_observed and last_observed - server_now > maximum_regression_ms then + return reply('CLOCK_UNSAFE', 'NONE', server_now, last_observed, limit, 0, 0, 0) +end +local effective_now = math.max(server_now, last_observed) +local window_id = math.floor(effective_now / window_ms) +if not exists then + previous_window_id = window_id - 1 + current_window_id = window_id +elseif window_id < current_window_id then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, effective_now, limit, 0, 0, 0) +elseif window_id == current_window_id + 1 then + previous_window_id = current_window_id + previous_count = current_count + current_window_id = window_id + current_count = 0 +elseif window_id > current_window_id + 1 then + previous_window_id = window_id - 1 + previous_count = 0 + current_window_id = window_id + current_count = 0 +end + +local window_start = window_id * window_ms +local elapsed = effective_now - window_start +local remaining_window = window_ms - elapsed +local previous_weight = ceiling_divide(remaining_window * SCALE, window_ms) +local weighted = current_count * SCALE + previous_count * previous_weight +local cost_scaled = cost * SCALE +local limit_scaled = limit * SCALE +if weighted > MAX_EXACT or cost_scaled > MAX_EXACT or weighted + cost_scaled > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end +local allowed = weighted + cost_scaled <= limit_scaled +if allowed then + current_count = current_count + cost + weighted = weighted + cost_scaled +end +local remaining = math.max(0, math.floor((limit_scaled - weighted) / SCALE)) +local reset_at = (window_id + 2) * window_ms +if reset_at > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end +local retry_after = 0 +if not allowed then + local current_base = (current_count + cost) * SCALE + if current_base <= limit_scaled and previous_count > 0 then + local maximum_previous_weight = math.floor((limit_scaled - current_base) / previous_count) + local maximum_remaining = math.floor(maximum_previous_weight * window_ms / SCALE) + retry_after = math.max(1, remaining_window - maximum_remaining) + elseif current_count + cost <= limit then + retry_after = math.max(1, remaining_window) + else + local maximum_previous_weight = math.floor((limit - cost) * SCALE / current_count) + local maximum_remaining = math.floor(maximum_previous_weight * window_ms / SCALE) + local elapsed_after_rollover = window_ms - maximum_remaining + retry_after = math.max(1, remaining_window + elapsed_after_rollover) + end +end +local ttl = 2 * window_ms + grace_ms +if ttl < 1 or ttl > 2 * MAX_WINDOW_MS + MAX_GRACE_MS then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end +local decision = allowed and 'ALLOWED' or 'DENIED' +local encoded = encoded_decision( + decision, server_now, effective_now, limit, remaining, retry_after, reset_at) +if encoded == nil then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end + +redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'algorithm', 'sliding-window-counter', + 'policyRevision', revision, + 'lastObservedMillis', number(effective_now), + 'previousWindowId', number(previous_window_id), + 'previousCount', number(previous_count), + 'currentWindowId', number(current_window_id), + 'currentCount', number(current_count)) +redis.call('PEXPIRE', KEYS[1], number(ttl)) + +if evaluation_id ~= '-' then + local expired = redis.call( + 'ZRANGEBYSCORE', + KEYS[3], + '-inf', + number(server_now - dedup_ttl_ms), + 'LIMIT', + 0, + maximum_dedup_entries) + for _, expired_id in ipairs(expired) do + redis.call('HDEL', KEYS[2], expired_id) + redis.call('ZREM', KEYS[3], expired_id) + end + if redis.call('ZCARD', KEYS[3]) >= maximum_dedup_entries then + local evicted = redis.call('ZPOPMIN', KEYS[3], 1) + if #evicted >= 1 then + redis.call('HDEL', KEYS[2], evicted[1]) + end + end + redis.call('HSET', KEYS[2], evaluation_id, encoded) + redis.call('ZADD', KEYS[3], number(server_now), evaluation_id) + redis.call('PEXPIRE', KEYS[2], number(dedup_ttl_ms)) + redis.call('PEXPIRE', KEYS[3], number(dedup_ttl_ms)) +end + +return reply( + decision, + decision, + server_now, + effective_now, + limit, + remaining, + retry_after, + reset_at) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v1.lua new file mode 100644 index 0000000..8326e05 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v1.lua @@ -0,0 +1,195 @@ +local MAX_EXACT = 9007199254740991 +local MAX_PERIOD_MS = 86400000 +local MAX_GRACE_MS = 86400000 +local MAX_REGRESSION_MS = 3600000 +local SCALE = 1000000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then + return nil + end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then + return nil + end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function ceiling_divide(numerator, denominator) + local quotient = math.floor(numerator / denominator) + if numerator % denominator == 0 then + return quotient + end + return quotient + 1 +end + +local function number(value) + return string.format('%.0f', value) +end + +local function reply(status, server_now, effective_now, limit, remaining, retry_after, reset_at) + return { + status, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + } +end + +local time = redis.call('TIME') +local server_now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local schema = integer(ARGV[1], 9999) +local revision = ARGV[2] +local capacity_scaled = integer(ARGV[3], MAX_EXACT) +local refill_scaled = integer(ARGV[4], MAX_EXACT) +local period_ms = integer(ARGV[5], MAX_PERIOD_MS) +local cost_scaled = integer(ARGV[6], MAX_EXACT) +local grace_ms = integer(ARGV[7], MAX_GRACE_MS) +local maximum_regression_ms = integer(ARGV[8], MAX_REGRESSION_MS) +if schema == nil or schema < 1 + or revision == nil or #revision < 1 or #revision > 64 + or capacity_scaled == nil or capacity_scaled < SCALE + or refill_scaled == nil or refill_scaled < 1 + or cost_scaled == nil or cost_scaled < SCALE or cost_scaled > capacity_scaled + or period_ms == nil or period_ms < 1 + or grace_ms == nil + or maximum_regression_ms == nil + or capacity_scaled > math.floor(MAX_EXACT / period_ms) + or server_now > MAX_EXACT then + return reply('INVALID', server_now, server_now, 0, 0, 0, 0) +end + +local capacity = math.floor(capacity_scaled / SCALE) +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', server_now, server_now, capacity, 0, 0, 0) +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'algorithm', + 'policyRevision', + 'lastObservedMillis', + 'tokensScaled', + 'lastRefillMillis', + 'refillRemainder') +local exists = state_type == 'hash' +local last_observed = server_now +local tokens_scaled = capacity_scaled +local last_refill = server_now +local refill_remainder = 0 +if exists then + if stored[1] ~= ARGV[1] + or stored[2] ~= 'token-bucket' + or stored[3] ~= revision then + return reply('STATE_INCOMPATIBLE', server_now, server_now, capacity, 0, 0, 0) + end + last_observed = integer(stored[4], MAX_EXACT) + tokens_scaled = integer(stored[5], capacity_scaled) + last_refill = integer(stored[6], MAX_EXACT) + refill_remainder = integer(stored[7], period_ms - 1) + if last_observed == nil or tokens_scaled == nil or last_refill == nil + or refill_remainder == nil or last_refill > last_observed then + return reply('STATE_INCOMPATIBLE', server_now, server_now, capacity, 0, 0, 0) + end +end + +if server_now < last_observed and last_observed - server_now > maximum_regression_ms then + return reply( + 'CLOCK_UNSAFE', + server_now, + last_observed, + capacity, + math.floor(tokens_scaled / SCALE), + 0, + 0) +end +local effective_now = math.max(server_now, last_observed) +local elapsed = math.max(0, effective_now - last_refill) +local full_refill_horizon = ceiling_divide(capacity_scaled * period_ms, refill_scaled) +local available +local new_refill_remainder +if elapsed >= full_refill_horizon then + available = capacity_scaled + new_refill_remainder = 0 +else + local elapsed_periods = math.floor(elapsed / period_ms) + local elapsed_remainder = elapsed % period_ms + local whole_period_tokens = elapsed_periods * refill_scaled + local partial_product = elapsed_remainder * refill_scaled + local partial_tokens = math.floor(partial_product / period_ms) + local partial_remainder = partial_product % period_ms + local combined_remainder = partial_remainder + refill_remainder + local remainder_carry = 0 + if combined_remainder >= period_ms then + combined_remainder = combined_remainder - period_ms + remainder_carry = 1 + end + local produced_scaled = whole_period_tokens + partial_tokens + remainder_carry + if tokens_scaled + produced_scaled >= capacity_scaled then + available = capacity_scaled + new_refill_remainder = 0 + else + available = tokens_scaled + produced_scaled + new_refill_remainder = combined_remainder + end +end + +local allowed = available >= cost_scaled +local new_tokens = available +if allowed then + new_tokens = available - cost_scaled +end +local retry_after = 0 +if not allowed then + local retry_numerator = (cost_scaled - available) * period_ms - new_refill_remainder + retry_after = math.max(1, ceiling_divide(retry_numerator, refill_scaled)) +end +local reset_numerator = (capacity_scaled - new_tokens) * period_ms - new_refill_remainder +local reset_delay = math.max(0, ceiling_divide(math.max(0, reset_numerator), refill_scaled)) +local reset_at = effective_now + reset_delay +local ttl = full_refill_horizon + grace_ms +if retry_after > MAX_EXACT + or reset_at > MAX_EXACT + or ttl < 1 + or ttl > MAX_EXACT then + return reply('INVALID', server_now, effective_now, 0, 0, 0, 0) +end + +redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'algorithm', 'token-bucket', + 'policyRevision', revision, + 'lastObservedMillis', number(effective_now), + 'tokensScaled', number(new_tokens), + 'lastRefillMillis', number(effective_now), + 'refillRemainder', number(new_refill_remainder)) +redis.call('PEXPIRE', KEYS[1], number(ttl)) + +return reply( + allowed and 'ALLOWED' or 'DENIED', + server_now, + effective_now, + capacity, + math.floor(new_tokens / SCALE), + retry_after, + reset_at) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v2.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v2.lua new file mode 100644 index 0000000..8cc2534 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/rate-token-bucket-v2.lua @@ -0,0 +1,370 @@ +local MAX_EXACT = 9007199254740991 +local MAX_PERIOD_MS = 86400000 +local MAX_GRACE_MS = 86400000 +local MAX_REGRESSION_MS = 3600000 +local MAXIMUM_EVALUATION_ID_BYTES = 71 +local MAXIMUM_DEDUP_ENTRIES = 1024 +local MAXIMUM_DEDUP_TTL_MS = 300000 +local MAXIMUM_DEDUP_DATA_BYTES = 262144 +local MAXIMUM_DEDUP_DECISION_BYTES = 128 +local SCALE = 1000000 + +local function integer(value, maximum) + if type(value) ~= 'string' or value == '' then + return nil + end + if value ~= '0' and string.match(value, '^[1-9][0-9]*$') == nil then + return nil + end + local parsed = tonumber(value) + if parsed == nil or parsed < 0 or parsed > maximum or parsed ~= math.floor(parsed) then + return nil + end + return parsed +end + +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function ceiling_divide(numerator, denominator) + local quotient = math.floor(numerator / denominator) + if numerator % denominator == 0 then + return quotient + end + return quotient + 1 +end + +local function number(value) + return string.format('%.0f', value) +end + +local function reply( + status, decision, server_now, effective_now, limit, remaining, retry_after, reset_at) + return { + status, + decision, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + } +end + +local function valid_evaluation_id(value) + if value == '-' then + return true + end + if type(value) ~= 'string' or #value > MAXIMUM_EVALUATION_ID_BYTES then + return false + end + local separator = string.find(value, ':', 1, true) + if separator == nil or string.sub(value, 1, 2) ~= 'ev' then + return false + end + local version = string.sub(value, 3, separator - 1) + local token = string.sub(value, separator + 1) + return #version >= 1 + and #version <= 4 + and string.match(version, '^[1-9][0-9]*$') ~= nil + and #token >= 22 + and #token <= 64 + and string.match(token, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function parse_replay(encoded, expected_limit) + if type(encoded) ~= 'string' or #encoded > MAXIMUM_DEDUP_DECISION_BYTES then + return nil + end + local fields = {} + for field in string.gmatch(encoded, '([^|]+)') do + table.insert(fields, field) + end + if #fields ~= 7 or (fields[1] ~= 'ALLOWED' and fields[1] ~= 'DENIED') then + return nil + end + local server_now = integer(fields[2], MAX_EXACT) + local effective_now = integer(fields[3], MAX_EXACT) + local limit = integer(fields[4], MAX_EXACT) + local remaining = integer(fields[5], MAX_EXACT) + local retry_after = integer(fields[6], MAX_EXACT) + local reset_at = integer(fields[7], MAX_EXACT) + if server_now == nil + or effective_now == nil + or limit ~= expected_limit + or remaining == nil or remaining > limit + or retry_after == nil + or reset_at == nil + or effective_now < server_now + or reset_at < effective_now + or (fields[1] == 'ALLOWED' and retry_after ~= 0) + or (fields[1] == 'DENIED' and retry_after < 1) then + return nil + end + return { + fields[1], server_now, effective_now, limit, remaining, retry_after, reset_at + } +end + +local function encoded_decision( + decision, server_now, effective_now, limit, remaining, retry_after, reset_at) + local encoded = table.concat( + { + decision, + number(server_now), + number(effective_now), + number(limit), + number(remaining), + number(retry_after), + number(reset_at) + }, + '|') + if #encoded > MAXIMUM_DEDUP_DECISION_BYTES then + return nil + end + return encoded +end + +local time = redis.call('TIME') +local server_now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local schema = integer(ARGV[1], 9999) +local revision = ARGV[2] +local capacity_scaled = integer(ARGV[3], MAX_EXACT) +local refill_scaled = integer(ARGV[4], MAX_EXACT) +local period_ms = integer(ARGV[5], MAX_PERIOD_MS) +local cost_scaled = integer(ARGV[6], MAX_EXACT) +local grace_ms = integer(ARGV[7], MAX_GRACE_MS) +local maximum_regression_ms = integer(ARGV[8], MAX_REGRESSION_MS) +local evaluation_id = ARGV[9] +local dedup_ttl_ms = integer(ARGV[10], MAXIMUM_DEDUP_TTL_MS) +local maximum_dedup_entries = integer(ARGV[11], MAXIMUM_DEDUP_ENTRIES) +local maximum_dedup_bytes = integer(ARGV[12], MAXIMUM_DEDUP_DATA_BYTES) +local dedup_enabled = dedup_ttl_ms ~= nil and dedup_ttl_ms > 0 +local dedup_shape_valid = dedup_enabled + and maximum_dedup_entries ~= nil and maximum_dedup_entries > 0 + and maximum_dedup_bytes ~= nil + and maximum_dedup_entries + * (MAXIMUM_EVALUATION_ID_BYTES + MAXIMUM_DEDUP_DECISION_BYTES) + <= maximum_dedup_bytes +local dedup_disabled = dedup_ttl_ms == 0 + and maximum_dedup_entries == 0 + and maximum_dedup_bytes == 0 +if schema ~= 2 + or revision == nil or #revision < 1 or #revision > 64 + or capacity_scaled == nil or capacity_scaled < SCALE + or refill_scaled == nil or refill_scaled < 1 + or cost_scaled == nil or cost_scaled < SCALE or cost_scaled > capacity_scaled + or period_ms == nil or period_ms < 1 + or grace_ms == nil + or maximum_regression_ms == nil + or capacity_scaled > math.floor(MAX_EXACT / period_ms) + or not valid_evaluation_id(evaluation_id) + or (not dedup_shape_valid and not dedup_disabled) + or (evaluation_id ~= '-' and not dedup_shape_valid) + or server_now > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, server_now, 0, 0, 0, 0) +end + +local capacity = math.floor(capacity_scaled / SCALE) +local state_type = key_type(KEYS[1]) +if state_type ~= 'none' and state_type ~= 'hash' then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) +end + +local stored = redis.call( + 'HMGET', + KEYS[1], + 'schema', + 'algorithm', + 'policyRevision', + 'lastObservedMillis', + 'tokensScaled', + 'lastRefillMillis', + 'refillRemainder') +local exists = state_type == 'hash' +local last_observed = server_now +local tokens_scaled = capacity_scaled +local last_refill = server_now +local refill_remainder = 0 +if exists then + if stored[1] ~= ARGV[1] + or stored[2] ~= 'token-bucket' + or stored[3] ~= revision then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) + end + last_observed = integer(stored[4], MAX_EXACT) + tokens_scaled = integer(stored[5], capacity_scaled) + last_refill = integer(stored[6], MAX_EXACT) + refill_remainder = integer(stored[7], period_ms - 1) + if last_observed == nil or tokens_scaled == nil or last_refill == nil + or refill_remainder == nil or last_refill > last_observed then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) + end +end + +if evaluation_id ~= '-' then + local dedup_type = key_type(KEYS[2]) + local order_type = key_type(KEYS[3]) + if (dedup_type ~= 'none' and dedup_type ~= 'hash') + or (order_type ~= 'none' and order_type ~= 'zset') + or (dedup_type == 'none') ~= (order_type == 'none') then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) + end + if dedup_type ~= 'none' then + local decisions = redis.call('HLEN', KEYS[2]) + local ordered = redis.call('ZCARD', KEYS[3]) + if decisions ~= ordered or decisions > maximum_dedup_entries then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) + end + local replay_encoded = redis.call('HGET', KEYS[2], evaluation_id) + if replay_encoded ~= false then + local replay_score = redis.call('ZSCORE', KEYS[3], evaluation_id) + local replay_timestamp = integer(replay_score, MAX_EXACT) + if replay_timestamp == nil then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) + end + if replay_timestamp <= server_now - dedup_ttl_ms then + redis.call('HDEL', KEYS[2], evaluation_id) + redis.call('ZREM', KEYS[3], evaluation_id) + else + local replayed = parse_replay(replay_encoded, capacity) + if replayed == nil then + return reply('STATE_INCOMPATIBLE', 'NONE', server_now, server_now, capacity, 0, 0, 0) + end + return reply( + 'DEDUP_REPLAY', + replayed[1], + replayed[2], + replayed[3], + replayed[4], + replayed[5], + replayed[6], + replayed[7]) + end + end + end +end + +if server_now < last_observed and last_observed - server_now > maximum_regression_ms then + return reply( + 'CLOCK_UNSAFE', + 'NONE', + server_now, + last_observed, + capacity, + math.floor(tokens_scaled / SCALE), + 0, + 0) +end +local effective_now = math.max(server_now, last_observed) +local elapsed = math.max(0, effective_now - last_refill) +local full_refill_horizon = ceiling_divide(capacity_scaled * period_ms, refill_scaled) +local available +local new_refill_remainder +if elapsed >= full_refill_horizon then + available = capacity_scaled + new_refill_remainder = 0 +else + local elapsed_periods = math.floor(elapsed / period_ms) + local elapsed_remainder = elapsed % period_ms + local whole_period_tokens = elapsed_periods * refill_scaled + local partial_product = elapsed_remainder * refill_scaled + local partial_tokens = math.floor(partial_product / period_ms) + local partial_remainder = partial_product % period_ms + local combined_remainder = partial_remainder + refill_remainder + local remainder_carry = 0 + if combined_remainder >= period_ms then + combined_remainder = combined_remainder - period_ms + remainder_carry = 1 + end + local produced_scaled = whole_period_tokens + partial_tokens + remainder_carry + if tokens_scaled + produced_scaled >= capacity_scaled then + available = capacity_scaled + new_refill_remainder = 0 + else + available = tokens_scaled + produced_scaled + new_refill_remainder = combined_remainder + end +end + +local allowed = available >= cost_scaled +local new_tokens = available +if allowed then + new_tokens = available - cost_scaled +end +local retry_after = 0 +if not allowed then + local retry_numerator = (cost_scaled - available) * period_ms - new_refill_remainder + retry_after = math.max(1, ceiling_divide(retry_numerator, refill_scaled)) +end +local reset_numerator = (capacity_scaled - new_tokens) * period_ms - new_refill_remainder +local reset_delay = math.max(0, ceiling_divide(math.max(0, reset_numerator), refill_scaled)) +local reset_at = effective_now + reset_delay +local ttl = full_refill_horizon + grace_ms +if retry_after > MAX_EXACT + or reset_at > MAX_EXACT + or ttl < 1 + or ttl > MAX_EXACT then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end +local remaining = math.floor(new_tokens / SCALE) +local decision = allowed and 'ALLOWED' or 'DENIED' +local encoded = encoded_decision( + decision, server_now, effective_now, capacity, remaining, retry_after, reset_at) +if encoded == nil then + return reply('INVALID', 'NONE', server_now, effective_now, 0, 0, 0, 0) +end + +redis.call( + 'HSET', + KEYS[1], + 'schema', ARGV[1], + 'algorithm', 'token-bucket', + 'policyRevision', revision, + 'lastObservedMillis', number(effective_now), + 'tokensScaled', number(new_tokens), + 'lastRefillMillis', number(effective_now), + 'refillRemainder', number(new_refill_remainder)) +redis.call('PEXPIRE', KEYS[1], number(ttl)) + +if evaluation_id ~= '-' then + local expired = redis.call( + 'ZRANGEBYSCORE', + KEYS[3], + '-inf', + number(server_now - dedup_ttl_ms), + 'LIMIT', + 0, + maximum_dedup_entries) + for _, expired_id in ipairs(expired) do + redis.call('HDEL', KEYS[2], expired_id) + redis.call('ZREM', KEYS[3], expired_id) + end + if redis.call('ZCARD', KEYS[3]) >= maximum_dedup_entries then + local evicted = redis.call('ZPOPMIN', KEYS[3], 1) + if #evicted >= 1 then + redis.call('HDEL', KEYS[2], evicted[1]) + end + end + redis.call('HSET', KEYS[2], evaluation_id, encoded) + redis.call('ZADD', KEYS[3], number(server_now), evaluation_id) + redis.call('PEXPIRE', KEYS[2], number(dedup_ttl_ms)) + redis.call('PEXPIRE', KEYS[3], number(dedup_ttl_ms)) +end + +return reply( + decision, + decision, + server_now, + effective_now, + capacity, + remaining, + retry_after, + reset_at) diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-bump-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-bump-v1.lua new file mode 100644 index 0000000..4ad0155 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-bump-v1.lua @@ -0,0 +1,72 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function valid_identifier(value) + return value ~= nil + and string.len(value) >= 16 + and string.len(value) <= 64 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function valid_ttl(value) + return value ~= nil + and string.match(value, '^%d+$') ~= nil + and tonumber(value) ~= nil + and tonumber(value) >= 0 + and tonumber(value) <= 2678400000 +end + +local function refresh_expiry(key, ttl) + if tonumber(ttl) == 0 then + redis.call('PERSIST', key) + else + redis.call('PEXPIRE', key, ttl) + end +end + +local function operation_from(value) + local separator = string.find(value, '|', 1, true) + if separator == nil or string.find(value, '|', separator + 1, true) ~= nil then + return nil + end + local generation = string.sub(value, 1, separator - 1) + local operation = string.sub(value, separator + 1) + if not valid_identifier(generation) + or (operation ~= '-' and not valid_identifier(operation)) then + return nil + end + return operation +end + +if #KEYS ~= 1 or #ARGV ~= 3 + or not valid_identifier(ARGV[1]) or not valid_identifier(ARGV[2]) + or not valid_ttl(ARGV[3]) then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type ~= 'none' and current_type ~= 'string' then + return 'WRONG_TYPE' +end +if current_type == 'string' then + local current_operation = operation_from(redis.call('GET', KEYS[1])) + if current_operation == nil then + return 'INVALID' + end + if current_operation == ARGV[2] then + refresh_expiry(KEYS[1], ARGV[3]) + return 'ALREADY_APPLIED' + end +end + +if tonumber(ARGV[3]) == 0 then + redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[2]) +else + redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[2], 'PX', ARGV[3]) +end +return 'BUMPED' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-init-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-init-v1.lua new file mode 100644 index 0000000..c0aff02 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/region-generation-init-v1.lua @@ -0,0 +1,69 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local function valid_identifier(value) + return value ~= nil + and string.len(value) >= 16 + and string.len(value) <= 64 + and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil +end + +local function valid_ttl(value) + return value ~= nil + and string.match(value, '^%d+$') ~= nil + and tonumber(value) ~= nil + and tonumber(value) >= 0 + and tonumber(value) <= 2678400000 +end + +local function refresh_expiry(key, ttl) + if tonumber(ttl) == 0 then + redis.call('PERSIST', key) + else + redis.call('PEXPIRE', key, ttl) + end +end + +local function valid_state(value) + local separator = string.find(value, '|', 1, true) + if separator == nil or string.find(value, '|', separator + 1, true) ~= nil then + return false + end + local generation = string.sub(value, 1, separator - 1) + local operation = string.sub(value, separator + 1) + return valid_identifier(generation) + and (operation == '-' or valid_identifier(operation)) +end + +if #KEYS ~= 1 or #ARGV ~= 2 + or not valid_identifier(ARGV[1]) or not valid_ttl(ARGV[2]) then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type ~= 'none' and current_type ~= 'string' then + return 'WRONG_TYPE' +end +if current_type == 'string' then + if not valid_state(redis.call('GET', KEYS[1])) then + return 'INVALID' + end + refresh_expiry(KEYS[1], ARGV[2]) + return 'EXISTING' +end + +local applied +if tonumber(ARGV[2]) == 0 then + applied = redis.call('SET', KEYS[1], ARGV[1] .. '|-', 'NX') +else + applied = redis.call('SET', KEYS[1], ARGV[1] .. '|-', 'PX', ARGV[2], 'NX') +end +if applied then + return 'INITIALIZED' +end +return 'EXISTING' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/replace-if-observed-with-ttl-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/replace-if-observed-with-ttl-v1.lua new file mode 100644 index 0000000..48e9193 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/replace-if-observed-with-ttl-v1.lua @@ -0,0 +1,30 @@ +local function key_type(key) + local result = redis.call('TYPE', key) + if type(result) == 'table' then + return result['ok'] + end + return result +end + +local ttl = tonumber(ARGV[3]) +if #KEYS ~= 1 or #ARGV ~= 4 or string.len(ARGV[1]) ~= 32 + or string.len(ARGV[2]) == 0 or ttl == nil or ttl < 1 + or string.len(ARGV[4]) == 0 or string.len(ARGV[4]) > 128 then + return 'INVALID' +end + +local current_type = key_type(KEYS[1]) +if current_type == 'none' then + return 'ABSENT' +end +if current_type ~= 'string' then + return 'WRONG_TYPE' +end + +local current_digest = redis.call('GETRANGE', KEYS[1], -32, -1) +if string.len(current_digest) ~= 32 or current_digest ~= ARGV[1] then + return 'NOT_MATCHED' +end + +redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl) +return 'REPLACED' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/semantic-capability-acl-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/semantic-capability-acl-v1.lua new file mode 100644 index 0000000..6c0d25d --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/semantic-capability-acl-v1.lua @@ -0,0 +1,77 @@ +if redis.REDIS_VERSION_NUM == nil or redis.REDIS_VERSION_NUM < 0x00070200 then + return 'VERSION_UNSUPPORTED' +end + +local function permitted(command, ...) + return redis.acl_check_cmd(command, ...) +end + +if #ARGV ~= 1 then + return 'INVALID' +end + +local capability = ARGV[1] +local ok = permitted('SCRIPT', 'LOAD', 'return 1') +if capability == 'CACHE' then + if #KEYS ~= 1 then return 'INVALID' end + ok = ok + and permitted('TYPE', KEYS[1]) + and permitted('SET', KEYS[1], 'semantic-ready-v1', 'PX', '5000', 'XX') +elseif capability == 'RATE_LIMIT' then + if #KEYS ~= 3 then return 'INVALID' end + ok = ok + and permitted('TYPE', KEYS[1]) + and permitted('TYPE', KEYS[2]) + and permitted('TYPE', KEYS[3]) + and permitted('TIME') + and permitted('HMGET', KEYS[1], 'field') + and permitted('HGET', KEYS[2], 'field') + and permitted('HSET', KEYS[1], 'field', 'value') + and permitted('HSET', KEYS[2], 'field', 'value') + and permitted('HDEL', KEYS[2], 'field') + and permitted('HLEN', KEYS[2]) + and permitted('PEXPIRE', KEYS[1], '5000') + and permitted('PEXPIRE', KEYS[2], '5000') + and permitted('PEXPIRE', KEYS[3], '5000') + and permitted('ZSCORE', KEYS[3], 'member') + and permitted('ZADD', KEYS[3], '1', 'member') + and permitted('ZREM', KEYS[3], 'member') + and permitted('ZCARD', KEYS[3]) + and permitted('ZRANGEBYSCORE', KEYS[3], '-inf', '+inf', 'LIMIT', '0', '1') + and permitted('ZPOPMIN', KEYS[3], '1') +elseif capability == 'IDEMPOTENCY' then + if #KEYS ~= 1 then return 'INVALID' end + ok = ok + and permitted('TYPE', KEYS[1]) + and permitted('TIME') + and permitted('HMGET', KEYS[1], 'field') + and permitted('HSET', KEYS[1], 'field', 'value') + and permitted('HDEL', KEYS[1], 'field') + and permitted('PEXPIRE', KEYS[1], '5000') +elseif capability == 'EFFICIENCY_LEASE' then + if #KEYS ~= 1 then return 'INVALID' end + ok = ok + and permitted('TYPE', KEYS[1]) + and permitted('TIME') + and permitted('HSET', KEYS[1], 'field', 'value') + and permitted('PEXPIRE', KEYS[1], '5000') + and permitted('HMGET', KEYS[1], 'field') + and permitted('PTTL', KEYS[1]) + and permitted('DEL', KEYS[1]) +elseif capability == 'SESSION' then + if #KEYS ~= 2 then return 'INVALID' end + ok = ok + and permitted('TIME') + and permitted('EXISTS', KEYS[1]) + and permitted('EXISTS', KEYS[2]) + and permitted('HMGET', KEYS[1], 'field') + and permitted('HSET', KEYS[1], 'field', 'value') + and permitted('PEXPIRE', KEYS[1], '5000') +else + return 'INVALID' +end + +if ok then + return 'ACL_OK' +end +return 'ACL_DENIED' diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-create-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-create-v1.lua new file mode 100644 index 0000000..2429b65 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-create-v1.lua @@ -0,0 +1,19 @@ +local now_parts = redis.call('TIME') +local now = (tonumber(now_parts[1]) * 1000) + math.floor(tonumber(now_parts[2]) / 1000) +local absolute = tonumber(ARGV[3]) +local idle = tonumber(ARGV[5]) +if absolute <= now then return {'ABSOLUTE_EXPIRED'} end +if redis.call('EXISTS', KEYS[2]) == 1 then return {'TOMBSTONED'} end +if redis.call('EXISTS', KEYS[1]) == 1 then + local existing = redis.call('HMGET', KEYS[1], 'last_op', 'digest', 'revision') + if existing[1] == ARGV[6] and existing[2] == ARGV[7] and existing[3] == ARGV[2] then + return {'ALREADY_CREATED_SAME_OPERATION'} + end + return {'EXISTS_CONFLICT'} +end +local ttl = math.min(idle, absolute - now) +redis.call('HSET', KEYS[1], + 'payload', ARGV[1], 'revision', ARGV[2], 'absolute', ARGV[3], + 'last_access', ARGV[4], 'idle', ARGV[5], 'last_op', ARGV[6], 'digest', ARGV[7]) +redis.call('PEXPIRE', KEYS[1], ttl) +return {'CREATED'} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-inspect-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-inspect-v1.lua new file mode 100644 index 0000000..79b2ade --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-inspect-v1.lua @@ -0,0 +1,16 @@ +if redis.call('EXISTS', KEYS[2]) == 1 then return {'TOMBSTONED', '', '', '', ''} end +local values = redis.call('HMGET', KEYS[1], 'payload', 'revision', 'absolute', 'last_access', 'idle') +if not values[1] then return {'ABSENT', '', '', '', ''} end +local absolute = tonumber(values[3]) +local last_access = tonumber(values[4]) +local idle = tonumber(values[5]) +local now = tonumber(ARGV[1]) +if absolute <= now then + redis.call('DEL', KEYS[1]) + return {'ABSOLUTE_EXPIRED', '', '', '', ''} +end +if last_access + idle <= now then + redis.call('DEL', KEYS[1]) + return {'ABSENT', '', '', '', ''} +end +return {'LIVE', values[1], values[2], values[3], values[4]} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-rotate-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-rotate-v1.lua new file mode 100644 index 0000000..dc8ad85 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-rotate-v1.lua @@ -0,0 +1,25 @@ +local now_parts = redis.call('TIME') +local now = (tonumber(now_parts[1]) * 1000) + math.floor(tonumber(now_parts[2]) / 1000) +local old_tomb_op = redis.call('HGET', KEYS[2], 'operation') +if old_tomb_op then + if old_tomb_op == ARGV[8] and redis.call('EXISTS', KEYS[3]) == 1 then + return {'ALREADY_ROTATED_SAME_OPERATION'} + end + return {'OLD_TOMBSTONED'} +end +if redis.call('EXISTS', KEYS[3]) == 1 or redis.call('EXISTS', KEYS[4]) == 1 then + return {'NEW_ID_CONFLICT'} +end +local current = redis.call('HMGET', KEYS[1], 'revision', 'absolute') +if not current[1] then return {'OLD_ABSENT'} end +if current[1] ~= ARGV[2] then return {'STALE_REVISION'} end +if tonumber(current[2]) <= now or tonumber(ARGV[4]) <= now then return {'ABSOLUTE_EXPIRED'} end +local ttl = math.min(tonumber(ARGV[6]), tonumber(ARGV[4]) - now) +redis.call('HSET', KEYS[3], + 'payload', ARGV[1], 'revision', ARGV[3], 'absolute', ARGV[4], + 'last_access', ARGV[5], 'idle', ARGV[6], 'last_op', ARGV[8], 'digest', ARGV[9]) +redis.call('PEXPIRE', KEYS[3], ttl) +redis.call('HSET', KEYS[2], 'operation', ARGV[8], 'revision', ARGV[2], 'new_id_digest', ARGV[10]) +redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[7])) +redis.call('DEL', KEYS[1]) +return {'ROTATED'} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-save-if-live-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-save-if-live-v1.lua new file mode 100644 index 0000000..8591f5e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-save-if-live-v1.lua @@ -0,0 +1,24 @@ +local now_parts = redis.call('TIME') +local now = (tonumber(now_parts[1]) * 1000) + math.floor(tonumber(now_parts[2]) / 1000) +if redis.call('EXISTS', KEYS[2]) == 1 then return {'TOMBSTONED'} end +local current = redis.call('HMGET', KEYS[1], 'revision', 'absolute', 'last_op', 'digest') +if not current[1] then return {'ABSENT'} end +if tonumber(current[2]) <= now then + redis.call('DEL', KEYS[1]) + return {'ABSOLUTE_EXPIRED'} +end +if current[3] == ARGV[7] then + if current[4] == ARGV[8] and current[1] == ARGV[3] then + return {'ALREADY_SAVED_SAME_OPERATION'} + end + return {'MUTATION_CONFLICT'} +end +if current[1] ~= ARGV[2] then return {'STALE_REVISION'} end +local absolute = tonumber(ARGV[4]) +if absolute <= now then return {'ABSOLUTE_EXPIRED'} end +local ttl = math.min(tonumber(ARGV[6]), absolute - now) +redis.call('HSET', KEYS[1], + 'payload', ARGV[1], 'revision', ARGV[3], 'absolute', ARGV[4], + 'last_access', ARGV[5], 'idle', ARGV[6], 'last_op', ARGV[7], 'digest', ARGV[8]) +redis.call('PEXPIRE', KEYS[1], ttl) +return {'SAVED'} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-tombstone-and-delete-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-tombstone-and-delete-v1.lua new file mode 100644 index 0000000..3e9ce47 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-tombstone-and-delete-v1.lua @@ -0,0 +1,15 @@ +local tomb_op = redis.call('HGET', KEYS[2], 'operation') +if tomb_op then + if tomb_op == ARGV[3] then return {'ALREADY_REVOKED_SAME_OPERATION'} end + if ARGV[1] == '0' then return {'TOMBSTONED_ABSENT'} end + return {'OPERATION_CONFLICT'} +end +local revision = redis.call('HGET', KEYS[1], 'revision') +if revision and ARGV[1] ~= '0' and revision ~= ARGV[1] then return {'STALE_REVISION'} end +redis.call('HSET', KEYS[2], 'operation', ARGV[3], 'revision', ARGV[1]) +redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[2])) +if revision then + redis.call('DEL', KEYS[1]) + return {'REVOKED_AND_DELETED'} +end +return {'TOMBSTONED_ABSENT'} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-touch-if-live-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-touch-if-live-v1.lua new file mode 100644 index 0000000..fc14fb6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/session-touch-if-live-v1.lua @@ -0,0 +1,17 @@ +local now_parts = redis.call('TIME') +local server_now = (tonumber(now_parts[1]) * 1000) + math.floor(tonumber(now_parts[2]) / 1000) +if redis.call('EXISTS', KEYS[2]) == 1 then return {'TOMBSTONED'} end +local current = redis.call('HMGET', KEYS[1], 'revision', 'absolute', 'last_access', 'last_op') +if not current[1] then return {'ABSENT'} end +if tonumber(current[2]) <= server_now then + redis.call('DEL', KEYS[1]) + return {'ABSOLUTE_EXPIRED'} +end +if current[4] == ARGV[6] then return {'ALREADY_TOUCHED_SAME_OPERATION'} end +if current[1] ~= ARGV[1] then return {'STALE_REVISION'} end +local requested_now = tonumber(ARGV[2]) +if requested_now - tonumber(current[3]) < tonumber(ARGV[5]) then return {'TOUCH_NOT_DUE'} end +local ttl = math.min(tonumber(ARGV[4]), tonumber(current[2]) - server_now) +redis.call('HSET', KEYS[1], 'last_access', ARGV[2], 'last_op', ARGV[6]) +redis.call('PEXPIRE', KEYS[1], ttl) +return {'TOUCHED'} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/zset-bounded-trim-v1.lua b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/zset-bounded-trim-v1.lua new file mode 100644 index 0000000..acff2d4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/scripts/zset-bounded-trim-v1.lua @@ -0,0 +1,56 @@ +local function invalid(detail) + return {'V1', 'INVALID', detail} +end +local function positive(value, maximum) + if not string.match(value, '^[1-9][0-9]*$') or #value > #maximum then + return false + end + return #value < #maximum or value <= maximum +end +local function finite_score(value) + if #value < 1 or #value > 27 or not string.match(value, '^-?[0-9]+%.?[0-9]*$') + or string.match(value, '^-?0[0-9]') + or string.sub(value, -1) == '.' + or value == '-0' then + return false + end + local parsed = tonumber(value) + return parsed ~= nil and parsed == parsed + and parsed >= -1000000000000000 and parsed <= 1000000000000000 +end +if #KEYS ~= 1 or #ARGV ~= 2 then + return invalid('ARITY') +end +if not finite_score(ARGV[1]) or not positive(ARGV[2], '1024') then + return invalid('INPUT') +end +local key = KEYS[1] +local kind = redis.call('TYPE', key).ok +if kind == 'none' then + return {'V1', 'TRIMMED', '0'} +end +if kind ~= 'zset' then + return {'V1', 'WRONG_TYPE', '-'} +end +if redis.call('PTTL', key) <= 0 then + return {'V1', 'MISSING_TTL', '-'} +end +local candidates = redis.call('ZCOUNT', key, '-inf', ARGV[1]) +local maximum = tonumber(ARGV[2]) +if candidates > maximum then + return {'V1', 'TOO_EXPENSIVE', ARGV[2]} +end +if candidates == 0 then + return {'V1', 'TRIMMED', '0'} +end +if not redis.acl_check_cmd('type', key) + or not redis.acl_check_cmd('pttl', key) + or not redis.acl_check_cmd('zcount', key, '-inf', ARGV[1]) + or not redis.acl_check_cmd('zremrangebyscore', key, '-inf', ARGV[1]) then + return invalid('ACL') +end +local removed = redis.call('ZREMRANGEBYSCORE', key, '-inf', ARGV[1]) +if removed ~= candidates then + return {'V1', 'CORRUPT_AFTER_WRITE', 'RESULT'} +end +return {'V1', 'TRIMMED', tostring(removed)} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/semantic-readiness-contract.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/semantic-readiness-contract.json new file mode 100644 index 0000000..73e9b37 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/semantic-readiness-contract.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": 1, + "namespacePrefix": "ca-health:", + "aclKeyPattern": "~ca-health:*", + "maximumTtlMillis": 5000, + "commonAclCommands": ["PING", "GET", "SET", "DEL", "EVALSHA", "SCRIPT|LOAD"], + "capabilityPrograms": { + "CACHE": "set-if-absent-with-ttl-v1", + "RATE_LIMIT": "rate-fixed-window-v2", + "IDEMPOTENCY": "idempotency-claim-v1", + "EFFICIENCY_LEASE": "lease-acquire-v1", + "SESSION": "session-create-v1" + }, + "aclProbeProgram": { + "id": "semantic-capability-acl-v1", + "scriptResource": "redis/scripts/semantic-capability-acl-v1.lua", + "sha256": "75e9be294e24aa3a9350bed85e92452749850372d215e4dd968e405e87d37bf7", + "minimumRedisVersion": "7.2", + "argumentCount": 1, + "resultSchema": { + "fieldCount": 1, + "maximumFieldBytes": 32, + "statuses": ["ACL_OK", "ACL_DENIED", "VERSION_UNSUPPORTED", "INVALID"] + }, + "capabilityAclSurfaces": { + "CACHE": { + "keyNames": ["entryKey"], + "commandKeyPositions": { + "TYPE": [1], + "SET": [1] + } + }, + "RATE_LIMIT": { + "keyNames": ["stateKey", "dedupHashKey", "dedupOrderKey"], + "commandKeyPositions": { + "TYPE": [1, 2, 3], + "TIME": [], + "HMGET": [1], + "HGET": [2], + "HSET": [1, 2], + "HDEL": [2], + "HLEN": [2], + "PEXPIRE": [1, 2, 3], + "ZSCORE": [3], + "ZADD": [3], + "ZREM": [3], + "ZCARD": [3], + "ZRANGEBYSCORE": [3], + "ZPOPMIN": [3] + } + }, + "IDEMPOTENCY": { + "keyNames": ["recordKey"], + "commandKeyPositions": { + "TYPE": [1], + "TIME": [], + "HMGET": [1], + "HSET": [1], + "HDEL": [1], + "PEXPIRE": [1] + } + }, + "EFFICIENCY_LEASE": { + "keyNames": ["leaseKey"], + "commandKeyPositions": { + "TYPE": [1], + "TIME": [], + "HSET": [1], + "PEXPIRE": [1], + "HMGET": [1], + "PTTL": [1], + "DEL": [1] + } + }, + "SESSION": { + "keyNames": ["liveSessionKey", "tombstoneKey"], + "commandKeyPositions": { + "TIME": [], + "EXISTS": [1, 2], + "HMGET": [1], + "HSET": [1], + "PEXPIRE": [1] + } + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis/session-program-set.json b/src/adapter/outbound/cache-redis/src/main/resources/redis/session-program-set.json new file mode 100644 index 0000000..bf4b86f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis/session-program-set.json @@ -0,0 +1,175 @@ +{ + "schemaVersion": 1, + "programSet": "ca-redis-session", + "semanticRevision": 1, + "applicationContractVersion": 1, + "minimumRedisVersion": "7.2", + "resultSchemaVersion": 1, + "readiness": "CANDIDATE", + "role": "SESSION", + "clusterSupport": "REJECTED_ROTATION_CROSS_SLOT", + "programs": [ + { + "id": "session-create-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_session_v1", + "registeredFunctionName": "ca_session_create_v1", + "scriptResource": "redis/scripts/session-create-v1.lua", + "sha256": "57ea119f093d974602278bbab36f5ca5b4305398c9fdc32d692ac28daae3e35e", + "keyCount": 2, + "argumentCount": 7, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "liveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "tombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "payloadBase64", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 2, "name": "newRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 3, "name": "absoluteExpiresAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 4, "name": "lastAccessedAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 5, "name": "idleTimeoutMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 6, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 7, "name": "payloadSha256", "type": "opaque-bytes", "maximumBytes": 1398104}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "live-and-tombstone-hashes", "maximumBytes": 1400000, "maximumEntries": 10}, + "ttl": {"mode": "DERIVED_AND_BOUNDED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "same-session-slot", "revision-and-time-bounds", "payload-and-digest-bounds", "operation-token-before-mutation"], + "statuses": ["CREATED", "ALREADY_CREATED_SAME_OPERATION", "EXISTS_CONFLICT", "TOMBSTONED", "ABSOLUTE_EXPIRED"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_OPERATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_OPERATION_ID", + "aclCommands": ["TIME", "EXISTS", "HMGET", "HSET", "PEXPIRE"] + }, + { + "id": "session-inspect-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_session_v1", + "registeredFunctionName": "ca_session_inspect_v1", + "scriptResource": "redis/scripts/session-inspect-v1.lua", + "sha256": "5bca491acf08741ae51769632a3e18d7db86f60e2e2bc4497243a95199000d8e", + "keyCount": 2, + "argumentCount": 1, + "replyFieldCount": 5, + "keys": [{"index": 1, "name": "liveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "tombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "clientNowMillis", "type": "opaque-bytes", "maximumBytes": 1398104}], + "resultSchema": {"version": 1, "fieldCount": 5, "maximumFieldBytes": 1398104, "orderedFields": ["status", "payloadBase64", "revision", "absoluteExpiresAtMillis", "lastAccessedAtMillis"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "live-and-tombstone-hashes", "maximumBytes": 1400000, "maximumEntries": 10}, + "ttl": {"mode": "READ_ONLY", "minimumMillis": 0, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "same-session-slot", "revision-and-time-bounds", "payload-and-digest-bounds", "operation-token-before-mutation"], + "statuses": ["LIVE", "TOMBSTONED", "ABSENT", "ABSOLUTE_EXPIRED"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields", + "clock": "CLIENT_SUPPLIED_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "READ_ONLY_RETRY_SAFE", + "timeoutCertainty": "READ_ONLY_RETRY_SAFE", + "aclCommands": ["EXISTS", "HMGET", "DEL"] + }, + { + "id": "session-save-if-live-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_session_v1", + "registeredFunctionName": "ca_session_save_if_live_v1", + "scriptResource": "redis/scripts/session-save-if-live-v1.lua", + "sha256": "f5ebe025e05d5232bc6c2bf5e5e14596f2f62e32cdddcfb44c3d9cee4a146f31", + "keyCount": 2, + "argumentCount": 8, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "liveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "tombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "payloadBase64", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 2, "name": "expectedRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 3, "name": "newRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 4, "name": "absoluteExpiresAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 5, "name": "lastAccessedAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 6, "name": "idleTimeoutMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 7, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 8, "name": "payloadSha256", "type": "opaque-bytes", "maximumBytes": 1398104}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "live-and-tombstone-hashes", "maximumBytes": 1400000, "maximumEntries": 10}, + "ttl": {"mode": "DERIVED_AND_BOUNDED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "same-session-slot", "revision-and-time-bounds", "payload-and-digest-bounds", "operation-token-before-mutation"], + "statuses": ["SAVED", "ALREADY_SAVED_SAME_OPERATION", "ABSENT", "STALE_REVISION", "MUTATION_CONFLICT", "TOMBSTONED", "ABSOLUTE_EXPIRED"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_OPERATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_OPERATION_ID", + "aclCommands": ["TIME", "EXISTS", "HMGET", "DEL", "HSET", "PEXPIRE"] + }, + { + "id": "session-touch-if-live-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_session_v1", + "registeredFunctionName": "ca_session_touch_if_live_v1", + "scriptResource": "redis/scripts/session-touch-if-live-v1.lua", + "sha256": "6ff6803ef9546104db7ad5343eeddd483714a0850e559af43618423f69301e28", + "keyCount": 2, + "argumentCount": 6, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "liveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "tombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "expectedRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 2, "name": "requestedNowMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 3, "name": "absoluteExpiresAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 4, "name": "idleTimeoutMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 5, "name": "touchIntervalMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 6, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 1398104}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "live-and-tombstone-hashes", "maximumBytes": 1400000, "maximumEntries": 10}, + "ttl": {"mode": "DERIVED_AND_BOUNDED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "same-session-slot", "revision-and-time-bounds", "payload-and-digest-bounds", "operation-token-before-mutation"], + "statuses": ["TOUCHED", "ALREADY_TOUCHED_SAME_OPERATION", "TOUCH_NOT_DUE", "ABSENT", "STALE_REVISION", "TOMBSTONED", "ABSOLUTE_EXPIRED"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_OPERATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_OPERATION_ID", + "aclCommands": ["TIME", "EXISTS", "HMGET", "DEL", "HSET", "PEXPIRE"] + }, + { + "id": "session-tombstone-and-delete-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_session_v1", + "registeredFunctionName": "ca_session_tombstone_and_delete_v1", + "scriptResource": "redis/scripts/session-tombstone-and-delete-v1.lua", + "sha256": "0a6a831177a19e6aab4db2084337f44da563342a5077e47fa823d09b8549d953", + "keyCount": 2, + "argumentCount": 3, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "liveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "tombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "expectedRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 2, "name": "tombstoneTtlMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 3, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 1398104}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "SAME_RESOURCE_HASH_TAG", + "state": {"type": "live-and-tombstone-hashes", "maximumBytes": 1400000, "maximumEntries": 10}, + "ttl": {"mode": "BOUNDED_REQUIRED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "same-session-slot", "revision-and-time-bounds", "payload-and-digest-bounds", "operation-token-before-mutation"], + "statuses": ["REVOKED_AND_DELETED", "TOMBSTONED_ABSENT", "ALREADY_REVOKED_SAME_OPERATION", "STALE_REVISION", "OPERATION_CONFLICT"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields", + "clock": "NONE", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_OPERATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_OPERATION_ID", + "aclCommands": ["HGET", "HSET", "PEXPIRE", "DEL"] + }, + { + "id": "session-rotate-v1", + "semanticVersion": "1.0.0", + "libraryName": "ca_session_v1", + "registeredFunctionName": "ca_session_rotate_v1", + "scriptResource": "redis/scripts/session-rotate-v1.lua", + "sha256": "f122b228b01c31118062d7d874ee2e5fcbcbbbc45a19518f4014c1c21ed6a361", + "keyCount": 4, + "argumentCount": 10, + "replyFieldCount": 1, + "keys": [{"index": 1, "name": "oldLiveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 2, "name": "oldTombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 3, "name": "newLiveSessionKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}, {"index": 4, "name": "newTombstoneKey", "type": "opaque-bytes", "maximumBytes": 512, "sameSlotGroup": "resource"}], + "arguments": [{"index": 1, "name": "payloadBase64", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 2, "name": "expectedRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 3, "name": "newRevision", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 4, "name": "absoluteExpiresAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 5, "name": "lastAccessedAtMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 6, "name": "idleTimeoutMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 7, "name": "tombstoneTtlMillis", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 8, "name": "operationId", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 9, "name": "payloadSha256", "type": "opaque-bytes", "maximumBytes": 1398104}, {"index": 10, "name": "newIdDigest", "type": "opaque-bytes", "maximumBytes": 1398104}], + "resultSchema": {"version": 1, "fieldCount": 1, "maximumFieldBytes": 128, "orderedFields": ["status"]}, + "slotRule": "CROSS_SLOT_UNSUPPORTED", + "state": {"type": "live-and-tombstone-hashes", "maximumBytes": 1400000, "maximumEntries": 10}, + "ttl": {"mode": "DERIVED_AND_BOUNDED", "minimumMillis": 1, "maximumMillis": 2592000000}, + "validateBeforeFirstWrite": ["key-count", "argument-count", "same-session-slot", "revision-and-time-bounds", "payload-and-digest-bounds", "operation-token-before-mutation"], + "statuses": ["ROTATED", "ALREADY_ROTATED_SAME_OPERATION", "OLD_ABSENT", "STALE_REVISION", "OLD_TOMBSTONED", "NEW_ID_CONFLICT", "ABSOLUTE_EXPIRED"], + "complexity": "O(1)", + "maximumIterations": 0, + "stateGrowth": "bounded-live-hash<=7-fields-and-tombstone-hash<=3-fields", + "clock": "REDIS_SERVER_TIME", + "minimumRedisVersion": "7.2", + "retrySafety": "REPLAYABLE_WITH_OPERATION_ID", + "timeoutCertainty": "REPLAYABLE_WITH_OPERATION_ID", + "aclCommands": ["TIME", "HGET", "EXISTS", "HMGET", "HSET", "PEXPIRE", "DEL"] + } + ] +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheCompatibilityEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheCompatibilityEvidenceTest.java new file mode 100644 index 0000000..e7ac9ed --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheCompatibilityEvidenceTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordIntent; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.LinkedHashSet; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-compatibility") +@Tag("card-redis-cache") +class RedisCacheCompatibilityEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + void cacheEnvelopeAtomicWritesAndGenerationInvalidationRunAcrossSupportedRedisVersions() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + LinkedHashSet supportedImages = new LinkedHashSet<>(); + supportedImages.add(images.requiredImage("redis.minimum.image")); + supportedImages.add(images.requiredImage("redis.next-minor.image")); + supportedImages.add(images.requiredImage("redis.approved.image")); + + for (String image : supportedImages) { + qualify(image); + } + } + + private static void qualify(String image) { + try (RedisStandaloneEvidenceContainer container = new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = new byte[32]; + RANDOM.nextBytes(hmacSecret); + try (RedisCacheRegionPolicy policy = policy(hmacSecret); + LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect(settings(container, hmacSecret))) { + RedisStringCacheRegion region = new RedisStringCacheRegion(policy, runtime); + assertThat( + region.record( + "compatibility-key", + "compatibility-value", + new CacheRecordMetadata("source-v1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + CacheLookup.Hit observed = + (CacheLookup.Hit) region.lookup("compatibility-key"); + assertThat( + region.record( + "compatibility-key", + "compatibility-value-v2", + new CacheRecordMetadata( + "source-v2", + CacheRecordIntent.ONLY_IF_OBSERVED, + observed.observationToken(), + observed.writeCondition()))) + .isEqualTo(CacheRecordOutcome.RECORDED); + assertThat(((CacheLookup.Hit) region.lookup("compatibility-key")).value()) + .isEqualTo("compatibility-value-v2"); + assertThat(region.invalidateRegion()).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + assertThat(region.lookup("compatibility-key")).isInstanceOf(CacheLookup.Miss.class); + } finally { + java.util.Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + private static RedisRuntimeSettings settings( + RedisStandaloneEvidenceContainer container, byte[] hmacSecret) { + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + container.host(), + container.port(), + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofSeconds(2), + Duration.ofSeconds(2), + Duration.ofSeconds(1), + Duration.ofSeconds(1), + 0.0, + Duration.ofMillis(10), + "ca-skeleton", + "qualification", + "cache-compatibility-evidence", + 128, + 8, + 1_048_576); + } + + private static RedisCacheRegionPolicy policy(byte[] hmacSecret) { + return new RedisCacheRegionPolicy( + new RedisKeyNamespace( + "ca-skeleton", "qualification", "cache", "compatibility", 1, 1, "entry", 512), + hmacSecret, + "redis-cache-compatibility-evidence-v1", + Duration.ofSeconds(1), + Duration.ofSeconds(5), + Duration.ofSeconds(1), + 0.0, + Duration.ofMillis(10), + 1024); + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheFaultEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheFaultEvidenceTest.java new file mode 100644 index 0000000..87f08c7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheFaultEvidenceTest.java @@ -0,0 +1,177 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordIntent; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-fault") +@Tag("card-redis-cache") +class RedisCacheFaultEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + void transportPartitionIsNeverReportedAsAMissAndMutationCertaintyIsExplicit() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisToxiproxyEvidenceContainer container = + new RedisToxiproxyEvidenceContainer( + images.requiredImage("redis.minimum.image"), images.requiredImage("toxiproxy.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (RedisCacheRegionPolicy policy = policy(hmacSecret); + LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect(settings(container, hmacSecret))) { + RedisStringCacheRegion region = new RedisStringCacheRegion(policy, runtime); + assertThat( + region.record( + "worklog-1", + "before-partition", + new CacheRecordMetadata("source-v1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + assertThat(region.lookup("worklog-1")).isInstanceOf(CacheLookup.Hit.class); + + container.disableProxy(); + + CacheLookup failedLookup = + awaitUnavailable(() -> region.lookup("worklog-1"), Duration.ofSeconds(5)); + assertThat(failedLookup).isInstanceOf(CacheLookup.Unavailable.class); + CacheLookup.Unavailable unavailable = + (CacheLookup.Unavailable) failedLookup; + assertThat(unavailable.certainty()) + .isIn( + CacheLookup.OperationCertainty.NOT_APPLIED, + CacheLookup.OperationCertainty.INDETERMINATE); + + assertThat( + region.record( + "worklog-2", + "during-partition", + new CacheRecordMetadata("source-v2", CacheRecordIntent.UPSERT))) + .isIn(CacheRecordOutcome.DEGRADED_UNAVAILABLE, CacheRecordOutcome.INDETERMINATE); + + container.enableProxy(); + assertThat( + await( + () -> region.lookup("worklog-1"), + result -> result instanceof CacheLookup.Hit, + Duration.ofSeconds(10))) + .isInstanceOf(CacheLookup.Hit.class); + } finally { + java.util.Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + void noEvictionOomNeverReportsASuccessfulCacheMutation() { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer( + image, + "redis-server", + "--save", + "", + "--appendonly", + "no", + "--maxmemory", + "512kb", + "--maxmemory-policy", + "noeviction")) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (RedisCacheRegionPolicy policy = policy(hmacSecret); + LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + settings(container.host(), container.port(), hmacSecret))) { + RedisStringCacheRegion region = new RedisStringCacheRegion(policy, runtime); + + assertThat( + region.record( + "worklog-oom", + "must-not-be-acknowledged", + new CacheRecordMetadata("source-oom", CacheRecordIntent.UPSERT))) + .isIn(CacheRecordOutcome.DEGRADED_UNAVAILABLE, CacheRecordOutcome.INDETERMINATE); + } finally { + java.util.Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + private static CacheLookup awaitUnavailable( + java.util.function.Supplier> lookup, Duration timeout) { + return await(lookup, result -> result instanceof CacheLookup.Unavailable, timeout); + } + + private static CacheLookup await( + java.util.function.Supplier> lookup, + java.util.function.Predicate> condition, + Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + CacheLookup result = lookup.get(); + while (!condition.test(result) && System.nanoTime() < deadline) { + try { + Thread.sleep(25); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("fault evidence wait was interrupted", exception); + } + result = lookup.get(); + } + return result; + } + + private static RedisRuntimeSettings settings( + RedisToxiproxyEvidenceContainer container, byte[] hmacSecret) { + return settings(container.host(), container.port(), hmacSecret); + } + + private static RedisRuntimeSettings settings(String host, int port, byte[] hmacSecret) { + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + host, + port, + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofSeconds(2), + Duration.ofSeconds(2), + Duration.ofMillis(250), + Duration.ofMillis(250), + 0.0, + Duration.ofMillis(10), + "ca-skeleton", + "qualification", + "cache-fault-evidence", + 128, + 8, + 1_048_576); + } + + private static RedisCacheRegionPolicy policy(byte[] hmacSecret) { + return new RedisCacheRegionPolicy( + new RedisKeyNamespace("ca-skeleton", "qualification", "cache", "fault", 1, 1, "entry", 512), + hmacSecret, + "redis-cache-fault-evidence-v1", + Duration.ofMillis(250), + Duration.ofSeconds(2), + Duration.ofMillis(250), + 0.0, + Duration.ofMillis(10), + 1024); + } + + private static byte[] randomSecret() { + byte[] value = new byte[32]; + RANDOM.nextBytes(value); + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java new file mode 100644 index 0000000..5af872e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheSecurityEvidenceTest.java @@ -0,0 +1,127 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("redis-security") +@Tag("card-redis-cache") +class RedisCacheSecurityEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + @TempDir Path materialDirectory; + + @Test + void canonicalRuntimeRequiresFullTlsExplicitTrustAndNamedAclAuthentication() { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer(image, materialDirectory, List.of("~cache:*"))) { + container.start(); + RedisDeploymentSettings.Standalone deployment = deployment(container); + RedisClientRuntimeSettings settings = runtimeSettings(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + deployment, settings, 65_536, credentials, trust, Clock.systemUTC())) { + runtime.set( + RedisPhysicalKeyTestFactory.fromEncoded("cache:tls-acl-key".getBytes(UTF_8)), + RedisBinaryValue.encoded("protected".getBytes(UTF_8)), + Duration.ofSeconds(5)); + assertThat( + runtime.get( + RedisPhysicalKeyTestFactory.fromEncoded("cache:tls-acl-key".getBytes(UTF_8)))) + .isEqualTo("protected".getBytes(UTF_8)); + assertThatThrownBy( + () -> + runtime.set( + RedisPhysicalKeyTestFactory.fromEncoded( + "session:cross-role-key".getBytes(UTF_8)), + RedisBinaryValue.encoded("forbidden".getBytes(UTF_8)), + Duration.ofSeconds(5))) + .isInstanceOf(RedisCommandFailureException.class); + } + + char[] wrongPassword = randomPassword(); + try { + RedisCredentialMaterialProvider wrongCredentials = + reference -> + new VersionedRedisCredentialMaterial( + "wrong-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(wrongPassword)); + assertThatThrownBy( + () -> + RedisTopologyCommandRuntime.connect( + deployment, settings, 65_536, wrongCredentials, trust, Clock.systemUTC())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Redis topology connect or probe failed"); + } finally { + Arrays.fill(wrongPassword, '\0'); + } + } + } + + private static RedisDeploymentSettings.Standalone deployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "cache-security-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication("app-user", "secret://evidence/redis-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/redis-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "cache-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } + + private static char[] randomPassword() { + char[] value = new char[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (char) ('a' + RANDOM.nextInt(26)); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStandaloneEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStandaloneEvidenceTest.java new file mode 100644 index 0000000..fee3040 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStandaloneEvidenceTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheObservationToken; +import dev.caskeleton.application.cache.CacheRecordIntent; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +@Tag("redis-standalone") +@Tag("card-redis-cache") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RedisCacheStandaloneEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private RedisStandaloneEvidenceContainer container; + + @BeforeAll + void startPinnedRedis() { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + container = new RedisStandaloneEvidenceContainer(image); + container.start(); + } + + @AfterAll + void stopPinnedRedis() { + if (container != null) { + container.close(); + } + } + + @Test + void realProviderPreservesTtlAndRejectsAWriterCapturedBeforeRegionInvalidation() + throws InterruptedException { + byte[] hmacSecret = new byte[32]; + RANDOM.nextBytes(hmacSecret); + RedisRuntimeSettings settings = + new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + container.host(), + container.port(), + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofSeconds(2), + Duration.ofSeconds(2), + Duration.ofMillis(500), + Duration.ofSeconds(1), + 0.0, + Duration.ofMillis(10), + "ca-skeleton", + "qualification", + "cache-evidence", + 1024, + 8, + 1_048_576); + RedisCacheRegionPolicy policy = + new RedisCacheRegionPolicy( + new RedisKeyNamespace( + "ca-skeleton", "qualification", "cache", "evidence", 1, 1, "entry", 512), + hmacSecret, + "redis-cache-evidence-v1", + Duration.ofMillis(500), + Duration.ofSeconds(2), + Duration.ofSeconds(1), + 0.0, + Duration.ofMillis(10), + 1024); + + try (policy; + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) { + RedisStringCacheRegion region = new RedisStringCacheRegion(policy, runtime); + assertThat( + region.record( + "worklog-1", + "old-value", + new CacheRecordMetadata("source-v1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + + CacheLookup.Hit captured = (CacheLookup.Hit) region.lookup("worklog-1"); + assertThat(captured.value()).isEqualTo("old-value"); + assertThat(region.invalidateRegion()).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + assertThat( + region.record( + "worklog-1", + "stale-writer", + new CacheRecordMetadata( + "source-v1", + CacheRecordIntent.ONLY_IF_ABSENT, + CacheObservationToken.unavailable(), + captured.writeCondition()))) + .isEqualTo(CacheRecordOutcome.NOT_RECORDED_CONDITION); + assertThat(region.lookup("worklog-1")).isInstanceOf(CacheLookup.Miss.class); + + assertThat( + region.record( + "worklog-1", + "fresh-value", + new CacheRecordMetadata("source-v2", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + Thread.sleep(2_100); + assertThat(region.lookup("worklog-1")).isInstanceOf(CacheLookup.Miss.class); + } finally { + java.util.Arrays.fill(hmacSecret, (byte) 0); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java new file mode 100644 index 0000000..824edf7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseEvidenceTest.java @@ -0,0 +1,306 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.application.lease.LeaseAcquireOutcome; +import dev.caskeleton.application.lease.LeaseAttempt; +import dev.caskeleton.application.lease.LeaseInspectionOutcome; +import dev.caskeleton.application.lease.LeaseInspectionRequest; +import dev.caskeleton.application.lease.LeaseReleaseOutcome; +import dev.caskeleton.application.lease.LeaseRequest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Real-service evidence for the non-fenced, owner-safe EFFICIENCY_ONLY lease. */ +@Tag("redis-efficiency-lease") +class RedisEfficiencyLeaseEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String RESOURCE_DIGEST = "hv1:" + "a".repeat(64); + + @Test + @Tag("redis-standalone") + void oneOwnerWinsAndAnExpiredOldOwnerCannotReleaseTheReplacement() throws Exception { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(images.requiredImage("redis.minimum.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisEfficiencyLeaseProvider provider = provider(runtime, hmacSecret); + var executor = Executors.newFixedThreadPool(2)) { + LeaseAttempt first = new LeaseAttempt("first_owner_token_1234", "first_operation_token_1"); + LeaseAttempt second = + new LeaseAttempt("second_owner_token_123", "second_operation_token_1"); + List outcomes = + executor + .invokeAll( + List.>of( + () -> provider.tryAcquire(request(first, Duration.ofMillis(100))), + () -> provider.tryAcquire(request(second, Duration.ofMillis(100))))) + .stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception failure) { + throw new AssertionError(failure); + } + }) + .toList(); + assertThat(outcomes).filteredOn(LeaseAcquireOutcome.Acquired.class::isInstance).hasSize(1); + assertThat(outcomes).filteredOn(LeaseAcquireOutcome.Contended.class::isInstance).hasSize(1); + LeaseAcquireOutcome.Acquired acquired = + (LeaseAcquireOutcome.Acquired) + outcomes.stream() + .filter(LeaseAcquireOutcome.Acquired.class::isInstance) + .findFirst() + .orElseThrow(); + LeaseAttempt winner = + new LeaseAttempt(acquired.handle().ownerToken(), acquired.handle().operationId()); + assertThat(provider.tryAcquire(request(winner, Duration.ofMillis(100)))) + .isInstanceOf(LeaseAcquireOutcome.ReplayedSameOperation.class); + + Thread.sleep(180); + LeaseAttempt replacement = + new LeaseAttempt("replacement_owner_token_1", "replacement_operation_1"); + LeaseAcquireOutcome.Acquired replacementAcquire = + (LeaseAcquireOutcome.Acquired) + provider.tryAcquire(request(replacement, Duration.ofSeconds(1))); + assertThat(acquired.handle().release()).isInstanceOf(LeaseReleaseOutcome.NotOwner.class); + assertThat( + provider.inspect( + new LeaseInspectionRequest("daily-export", RESOURCE_DIGEST, replacement))) + .isInstanceOf(LeaseInspectionOutcome.Owned.class); + assertThat(replacementAcquire.handle().release()) + .isInstanceOf(LeaseReleaseOutcome.Released.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-security") + void namedAclAndExplicitTlsTrustProtectEfficiencyLeaseState( + @TempDir java.nio.file.Path materials) { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer(images.requiredImage("redis.minimum.image"), materials)) { + container.start(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "efficiency-lease-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "efficiency-lease-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + byte[] hmacSecret = randomSecret(); + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + secureDeployment(container), + runtimeSettings(), + 65_536, + credentials, + trust, + Clock.systemUTC()); + RedisEfficiencyLeaseProvider provider = provider(runtime, hmacSecret)) { + LeaseAttempt attempt = + new LeaseAttempt("security_owner_token_12", "security_operation_tok"); + LeaseAcquireOutcome.Acquired acquired = + (LeaseAcquireOutcome.Acquired) + provider.tryAcquire(request(attempt, Duration.ofSeconds(1))); + assertThat(acquired.handle().release()).isInstanceOf(LeaseReleaseOutcome.Released.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-fault") + void partitionNeverInventsOwnershipAndTheRetainedAttemptReconcilesAfterRecovery() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisToxiproxyEvidenceContainer container = + new RedisToxiproxyEvidenceContainer( + images.requiredImage("redis.minimum.image"), images.requiredImage("toxiproxy.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisEfficiencyLeaseProvider provider = provider(runtime, hmacSecret)) { + LeaseAttempt attempt = + new LeaseAttempt("fault_owner_token_12345", "fault_operation_token1"); + container.disableProxy(); + LeaseAcquireOutcome failed = + await( + () -> provider.tryAcquire(request(attempt, Duration.ofSeconds(1))), + outcome -> + outcome instanceof LeaseAcquireOutcome.Unavailable + || outcome instanceof LeaseAcquireOutcome.Indeterminate, + Duration.ofSeconds(5)); + assertThat(failed) + .isInstanceOfAny( + LeaseAcquireOutcome.Unavailable.class, LeaseAcquireOutcome.Indeterminate.class); + + container.enableProxy(); + LeaseAcquireOutcome reconciled = + await( + () -> provider.tryAcquire(request(attempt, Duration.ofSeconds(1))), + outcome -> + outcome instanceof LeaseAcquireOutcome.Acquired + || outcome instanceof LeaseAcquireOutcome.ReplayedSameOperation, + Duration.ofSeconds(10)); + assertThat(reconciled) + .isInstanceOfAny( + LeaseAcquireOutcome.Acquired.class, + LeaseAcquireOutcome.ReplayedSameOperation.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-compatibility") + void efficiencyLeaseProgramsRunAcrossPinnedSupportedRedisVersions() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + LinkedHashSet supportedImages = new LinkedHashSet<>(); + supportedImages.add(images.requiredImage("redis.minimum.image")); + supportedImages.add(images.requiredImage("redis.next-minor.image")); + supportedImages.add(images.requiredImage("redis.approved.image")); + + int index = 0; + for (String image : supportedImages) { + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisEfficiencyLeaseProvider provider = provider(runtime, hmacSecret)) { + LeaseAttempt attempt = + new LeaseAttempt("compat_owner_token_1234" + index, "compat_operation_token" + index); + LeaseAcquireOutcome.Acquired acquired = + (LeaseAcquireOutcome.Acquired) + provider.tryAcquire(request(attempt, Duration.ofSeconds(1))); + assertThat(provider.tryAcquire(request(attempt, Duration.ofSeconds(1)))) + .isInstanceOf(LeaseAcquireOutcome.ReplayedSameOperation.class); + assertThat(acquired.handle().release()).isInstanceOf(LeaseReleaseOutcome.Released.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + index++; + } + } + + private static RedisEfficiencyLeaseProvider provider( + RedisStructuredCommands commands, byte[] hmacSecret) { + return RedisEfficiencyLeaseProvider.create( + "ca-skeleton", + "qualification", + 1, + 1, + hmacSecret, + commands, + Clock.systemUTC(), + Duration.ofMillis(10)); + } + + private static LeaseRequest request(LeaseAttempt attempt, Duration ttl) { + return new LeaseRequest("daily-export", RESOURCE_DIGEST, Duration.ZERO, ttl, attempt); + } + + private static RedisLegacyStandaloneSettings connection( + String host, int port, byte[] hmacSecret) { + return new RedisLegacyStandaloneSettings( + host, + port, + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofMillis(300), + 65_536, + 32, + 1_048_576, + "ca-skeleton", + "qualification"); + } + + private static RedisDeploymentSettings.Standalone secureDeployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "efficiency-lease-security-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication( + "app-user", "secret://evidence/efficiency-lease-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/efficiency-lease-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "efficiency-lease-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } + + private static byte[] randomSecret() { + byte[] value = new byte[32]; + RANDOM.nextBytes(value); + return value; + } + + private static T await(Supplier supplier, Predicate condition, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + T result = supplier.get(); + while (!condition.test(result) && System.nanoTime() < deadline) { + try { + Thread.sleep(25); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "efficiency lease evidence wait was interrupted", exception); + } + result = supplier.get(); + } + return result; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEvidenceImageRegistry.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEvidenceImageRegistry.java new file mode 100644 index 0000000..20509dc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEvidenceImageRegistry.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Properties; +import java.util.regex.Pattern; + +final class RedisEvidenceImageRegistry { + + private static final Pattern PINNED_IMAGE = + Pattern.compile("^[^\\s@:]+(?:/[^\\s@:]+)*:[^\\s@]+@sha256:[0-9a-f]{64}$"); + + private final Properties properties; + + private RedisEvidenceImageRegistry(Properties properties) { + this.properties = properties; + } + + static RedisEvidenceImageRegistry load() { + String configured = System.getProperty("redis.image.registry"); + if (configured == null || configured.isBlank()) { + throw new IllegalStateException("redis.image.registry system property is required"); + } + Path registry = Path.of(configured).toAbsolutePath().normalize(); + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(registry)) { + values.load(input); + } catch (IOException exception) { + throw new IllegalStateException("cannot load the Redis evidence image registry", exception); + } + return new RedisEvidenceImageRegistry(values); + } + + String requiredImage(String key) { + String image = Objects.toString(properties.getProperty(key), "").trim(); + if (!PINNED_IMAGE.matcher(image).matches() || image.contains(":latest@")) { + throw new IllegalStateException("Redis evidence image must use an exact tag and digest"); + } + return image; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java new file mode 100644 index 0000000..43e3daa --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyEvidenceTest.java @@ -0,0 +1,341 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStartOutcome; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("card-redis-request-replay-idempotency") +class RedisIdempotencyEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("a".repeat(64)); + + @Test + @Tag("redis-standalone") + void concurrentClaimsHaveOneOwnerAndCompletedResponseReplays() throws Exception { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(images.requiredImage("redis.minimum.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisIdempotencyStoreProvider provider = provider(runtime, hmacSecret); + var executor = Executors.newFixedThreadPool(2)) { + IdempotencyScope scope = scope("standalone"); + IdempotencyClaimRequest first = + request(scope, attempt("first_owner_token_1234", "first_operation_token_1")); + IdempotencyClaimRequest second = + request(scope, attempt("second_owner_token_123", "second_operation_token_1")); + List outcomes = + executor + .invokeAll( + List.>of( + () -> provider.claim(first), () -> provider.claim(second))) + .stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception exception) { + throw new AssertionError(exception); + } + }) + .toList(); + assertThat(outcomes) + .filteredOn(IdempotencyClaimOutcome.Acquired.class::isInstance) + .hasSize(1); + assertThat(outcomes) + .filteredOn(IdempotencyClaimOutcome.InProgress.class::isInstance) + .hasSize(1); + + IdempotencyClaimRequest winner = + outcomes.get(0) instanceof IdempotencyClaimOutcome.Acquired ? first : second; + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) + outcomes.stream() + .filter(IdempotencyClaimOutcome.Acquired.class::isInstance) + .findFirst() + .orElseThrow(); + complete(provider, acquired, winner.claimAttempt().operationId()); + + IdempotencyClaimOutcome replay = + provider.claim( + request(scope, attempt("replay_owner_token_1234", "replay_operation_token1"))); + assertThat(replay).isInstanceOf(IdempotencyClaimOutcome.CompletedReplay.class); + assertThat(((IdempotencyClaimOutcome.CompletedReplay) replay).response()) + .isEqualTo(new StoredResponse("created")); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-security") + void namedAclAndExplicitTlsTrustProtectIdempotencyState(@TempDir java.nio.file.Path materials) { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer(images.requiredImage("redis.minimum.image"), materials)) { + container.start(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "idempotency-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "idempotency-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + byte[] hmacSecret = randomSecret(); + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + secureDeployment(container), + runtimeSettings(), + 65_536, + credentials, + trust, + Clock.systemUTC()); + RedisIdempotencyStoreProvider provider = provider(runtime, hmacSecret)) { + IdempotencyClaimRequest request = + request( + scope("security"), attempt("security_owner_token_12", "security_operation_tok")); + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) provider.claim(request); + complete(provider, acquired, request.claimAttempt().operationId()); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-fault") + void partitionDoesNotInventAnOwnerAndSameClaimOperationReconcilesAfterRecovery() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisToxiproxyEvidenceContainer container = + new RedisToxiproxyEvidenceContainer( + images.requiredImage("redis.minimum.image"), images.requiredImage("toxiproxy.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisIdempotencyStoreProvider provider = provider(runtime, hmacSecret)) { + IdempotencyClaimRequest request = + request(scope("fault"), attempt("fault_owner_token_12345", "fault_operation_token1")); + container.disableProxy(); + IdempotencyClaimOutcome failed = + await( + () -> provider.claim(request), + outcome -> + outcome instanceof IdempotencyClaimOutcome.Unavailable + || outcome instanceof IdempotencyClaimOutcome.Indeterminate, + Duration.ofSeconds(5)); + assertThat(failed) + .isInstanceOfAny( + IdempotencyClaimOutcome.Unavailable.class, + IdempotencyClaimOutcome.Indeterminate.class); + + container.enableProxy(); + IdempotencyClaimOutcome reconciled = + await( + () -> provider.claim(request), + outcome -> + outcome instanceof IdempotencyClaimOutcome.Acquired + || outcome instanceof IdempotencyClaimOutcome.ReplayedAcquire, + Duration.ofSeconds(10)); + assertThat(reconciled) + .isInstanceOfAny( + IdempotencyClaimOutcome.Acquired.class, + IdempotencyClaimOutcome.ReplayedAcquire.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-compatibility") + void idempotencyLifecycleRunsAcrossPinnedSupportedRedisVersions() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + LinkedHashSet supportedImages = new LinkedHashSet<>(); + supportedImages.add(images.requiredImage("redis.minimum.image")); + supportedImages.add(images.requiredImage("redis.next-minor.image")); + supportedImages.add(images.requiredImage("redis.approved.image")); + + int index = 0; + for (String image : supportedImages) { + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisIdempotencyStoreProvider provider = provider(runtime, hmacSecret)) { + IdempotencyClaimRequest request = + request( + scope("compatibility-" + index), + attempt("compat_owner_token_1234" + index, "compat_operation_token" + index)); + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) provider.claim(request); + complete(provider, acquired, request.claimAttempt().operationId()); + assertThat( + provider.claim( + request( + request.scope(), + attempt( + "compat_replay_owner_12" + index, + "compat_replay_operation" + index)))) + .isInstanceOf(IdempotencyClaimOutcome.CompletedReplay.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + index++; + } + } + + private static void complete( + RedisIdempotencyStoreProvider provider, + IdempotencyClaimOutcome.Acquired acquired, + String operationId) { + assertThat(provider.markExecutionStarted(acquired.owner(), operationId).status()) + .isEqualTo(IdempotencyStartOutcome.Status.STARTED); + assertThat( + provider + .complete( + acquired.owner(), + new StoredResponse("created"), + Duration.ofSeconds(5), + operationId) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.COMPLETED); + } + + private static RedisIdempotencyStoreProvider provider( + RedisStructuredCommands commands, byte[] hmacSecret) { + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + return new RedisIdempotencyStoreProvider( + new RedisIdempotencyKeyFactory("ca-skeleton", "qualification", 1, 1, hmacSecret), + new RedisIdempotencyProgramExecutor(catalog, commands), + new RedisIdempotencyRecordCodec(), + new RedisIdempotencyTokenGenerator(RANDOM)); + } + + private static IdempotencyClaimRequest request( + IdempotencyScope scope, IdempotencyClaimAttempt attempt) { + return new IdempotencyClaimRequest( + scope, + FINGERPRINT, + attempt, + Duration.ofSeconds(1), + Duration.ofSeconds(5), + "json-v2", + "policy-v2"); + } + + private static IdempotencyClaimAttempt attempt(String owner, String operation) { + return new IdempotencyClaimAttempt(owner, operation); + } + + private static IdempotencyScope scope(String suffix) { + return IdempotencyScope.of("principal-" + suffix, "key-" + suffix, "create-worklog"); + } + + private static RedisLegacyStandaloneSettings connection( + String host, int port, byte[] hmacSecret) { + return new RedisLegacyStandaloneSettings( + host, + port, + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofMillis(300), + 65_536, + 32, + 1_048_576, + "ca-skeleton", + "qualification"); + } + + private static RedisDeploymentSettings.Standalone secureDeployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "idempotency-security-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication( + "app-user", "secret://evidence/idempotency-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/idempotency-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "idempotency-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } + + private static byte[] randomSecret() { + byte[] value = new byte[32]; + RANDOM.nextBytes(value); + return value; + } + + private static T await(Supplier supplier, Predicate condition, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + T result = supplier.get(); + while (!condition.test(result) && System.nanoTime() < deadline) { + try { + Thread.sleep(25); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("idempotency evidence wait was interrupted", exception); + } + result = supplier.get(); + } + return result; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java new file mode 100644 index 0000000..fcc33c3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; + +/** Evidence-test-only backdoor for terminal-adapter fault injection. */ +final class RedisPhysicalKeyTestFactory { + + private RedisPhysicalKeyTestFactory() {} + + static RedisPhysicalKey fromEncoded(byte[] encoded) { + try { + Constructor constructor = + RedisPhysicalKey.class.getDeclaredConstructor(byte[].class); + constructor.setAccessible(true); + return constructor.newInstance((Object) encoded.clone()); + } catch (ReflectiveOperationException exception) { + throw new LinkageError("RedisPhysicalKey test constructor is unavailable", exception); + } + } + + static RedisPhysicalKey fromUtf8(String value) { + return fromEncoded(value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java new file mode 100644 index 0000000..13e5054 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCatalogEvidenceTest.java @@ -0,0 +1,751 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("redis-standalone") +class RedisPrimitiveCatalogEvidenceTest { + + @TempDir Path materials; + + @Test + void independentCanonicalClientsPreserveAtomicityTtlBoundsAndWrongTypeCertainty() + throws Exception { + try (Harness harness = harness("atomic")) { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + try (Pod cacheA = harness.pod(RedisRole.CACHE, "primitive-cache-a"); + Pod cacheB = harness.pod(RedisRole.CACHE, "primitive-cache-b"); + Pod coordinationA = harness.pod(RedisRole.COORDINATION, "primitive-coordination-a"); + Pod coordinationB = harness.pod(RedisRole.COORDINATION, "primitive-coordination-b")) { + RedisStringValuePrimitives stringsA = catalog.strings(cacheA.router); + RedisStringValuePrimitives stringsB = catalog.strings(cacheB.router); + RedisPrimitiveKey winnerKey = stringsA.key("atomic", "winner"); + List> contenders = + java.util.stream.IntStream.range(0, 16) + .>mapToObj( + index -> + () -> + (index & 1) == 0 + ? stringsA.compareSetAbsent( + winnerKey, stringsA.value("a-" + index), Duration.ofSeconds(5)) + : stringsB.compareSetAbsent( + winnerKey, stringsB.value("b-" + index), Duration.ofSeconds(5))) + .toList(); + try (var executor = Executors.newFixedThreadPool(8)) { + long winners = + executor.invokeAll(contenders).stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }) + .filter(result -> result.status() == RedisPrimitiveMutationResult.Status.APPLIED) + .count(); + assertThat(winners).isOne(); + } + assertThat(stringsB.get(winnerKey).status()).isEqualTo(RedisPrimitiveReply.Status.PRESENT); + + RedisHashPrimitives hashesA = catalog.hashes(cacheA.router); + RedisHashPrimitives hashesB = catalog.hashes(cacheB.router); + RedisPrimitiveKey revisionKey = hashesA.key("atomic", "revision-winner"); + List> revisionContenders = + java.util.stream.IntStream.range(0, 16) + .>mapToObj( + index -> + () -> + (index & 1) == 0 + ? hashesA.compareRevision( + revisionKey, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind + .ABSENT, + "", + "ra" + index, + hashesA.value("a-" + index), + Duration.ofSeconds(5)) + : hashesB.compareRevision( + revisionKey, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind + .ABSENT, + "", + "rb" + index, + hashesB.value("b-" + index), + Duration.ofSeconds(5))) + .toList(); + try (var executor = Executors.newFixedThreadPool(8)) { + assertThat( + executor.invokeAll(revisionContenders).stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }) + .filter( + result -> result.status() == RedisPrimitiveMutationResult.Status.APPLIED) + .count()) + .isOne(); + } + RedisPrimitiveValue revisionField = hashesA.field("_revision"); + RedisPrimitiveValue valueField = hashesA.field("value"); + byte[] winningRevision = + hashesA.get(revisionKey, revisionField).values().getFirst().copyEncoded(); + byte[] winningValue = + hashesA.get(revisionKey, valueField).values().getFirst().copyEncoded(); + RedisPrimitiveMutationResult staleRevision = + hashesB.compareRevision( + revisionKey, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.VALUE, + "stale-revision", + "newer-revision", + hashesB.value("must-not-overwrite"), + Duration.ofSeconds(5)); + assertThat(staleRevision.status()).isEqualTo(RedisPrimitiveMutationResult.Status.MISMATCH); + assertThat(staleRevision.certainty()) + .isEqualTo(RedisPrimitiveMutationResult.Certainty.NOT_APPLIED); + assertThat(hashesB.get(revisionKey, revisionField).values().getFirst().copyEncoded()) + .isEqualTo(winningRevision); + assertThat(hashesB.get(revisionKey, valueField).values().getFirst().copyEncoded()) + .isEqualTo(winningValue); + + RedisCounterPrimitives countersA = catalog.counters(coordinationA.router); + RedisCounterPrimitives countersB = catalog.counters(coordinationB.router); + RedisPrimitiveKey counterKey = countersA.key("atomic", "counter"); + List> increments = + java.util.stream.IntStream.range(0, 100) + .>mapToObj( + index -> + () -> + (index & 1) == 0 + ? countersA.increment(counterKey, 1, 0, 100, Duration.ofSeconds(5)) + : countersB.increment(counterKey, 1, 0, 100, Duration.ofSeconds(5))) + .toList(); + try (var executor = Executors.newFixedThreadPool(8)) { + assertThat( + executor.invokeAll(increments).stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + })) + .allSatisfy( + result -> + assertThat(result.status()).isEqualTo(RedisCounterResult.Status.UPDATED)); + } + assertThat(countersB.read(counterKey).signedNumber()).hasValue(100); + RedisCounterResult limit = + countersA.increment(counterKey, 1, 0, 100, Duration.ofSeconds(5)); + assertThat(limit.status()).isEqualTo(RedisCounterResult.Status.LIMIT_EXCEEDED); + assertThat(limit.value()).hasValue(100); + RedisPrimitiveKey expiringCounter = countersA.key("atomic", "expiring-counter"); + countersA.increment(expiringCounter, 1, 0, 100, Duration.ofMillis(300)); + awaitMissing(() -> countersA.read(expiringCounter)); + + RedisSetPrimitives sets = catalog.sets(cacheA.router); + RedisSetPrimitives setsB = catalog.sets(cacheB.router); + RedisPrimitiveKey boundedSet = sets.key("atomic", "bounded-set"); + List> setAdmissions = + java.util.stream.IntStream.range(0, 300) + .>mapToObj( + index -> + () -> + (index & 1) == 0 + ? sets.admit( + boundedSet, + sets.member("member-" + index), + Duration.ofSeconds(5)) + : setsB.admit( + boundedSet, + setsB.member("member-" + index), + Duration.ofSeconds(5))) + .toList(); + try (var executor = Executors.newFixedThreadPool(8)) { + executor + .invokeAll(setAdmissions) + .forEach( + future -> { + try { + assertThat(future.get().status()) + .isIn( + RedisPrimitiveMutationResult.Status.APPLIED, + RedisPrimitiveMutationResult.Status.CAPACITY_EXCEEDED); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }); + } + assertThat(sets.cardinality(boundedSet).signedNumber()).hasValue(256); + + RedisListPrimitives listsA = catalog.lists(cacheA.router); + RedisListPrimitives listsB = catalog.lists(cacheB.router); + RedisPrimitiveKey boundedList = listsA.key("atomic", "bounded-list"); + List> listAdmissions = + java.util.stream.IntStream.range(0, 300) + .>mapToObj( + index -> + () -> + (index & 1) == 0 + ? listsA.admit( + boundedList, + listsA.value("value-" + index), + Duration.ofSeconds(5)) + : listsB.admit( + boundedList, + listsB.value("value-" + index), + Duration.ofSeconds(5))) + .toList(); + try (var executor = Executors.newFixedThreadPool(8)) { + executor + .invokeAll(listAdmissions) + .forEach( + future -> { + try { + assertThat(future.get().status()) + .isIn( + RedisPrimitiveMutationResult.Status.APPLIED, + RedisPrimitiveMutationResult.Status.CAPACITY_EXCEEDED); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }); + } + int popped = 0; + while (listsA.pop(boundedList).status() == RedisPrimitiveReply.Status.PRESENT) { + popped++; + } + assertThat(popped).isEqualTo(256); + + RedisPrimitiveKey wrongType = sets.key("atomic", "wrong-type"); + cacheA.runtime.set( + wrongType.physicalKey(), RedisBinaryValue.utf8("not-a-set"), Duration.ofSeconds(5)); + assertThat(sets.contains(wrongType, sets.member("member")).status()) + .isEqualTo(RedisPrimitiveReply.Status.WRONG_TYPE); + RedisPrimitiveMutationResult wrongTypeMutation = + sets.admit(wrongType, sets.member("member"), Duration.ofSeconds(5)); + assertThat(wrongTypeMutation.status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.WRONG_TYPE); + assertThat(wrongTypeMutation.certainty()) + .isEqualTo(RedisPrimitiveMutationResult.Certainty.NOT_APPLIED); + + RedisTopologyCommandRuntime lossDelegate = harness.runtime("primitive-loss-delegate"); + LossAfterApplyRuntime lossRuntime = new LossAfterApplyRuntime(lossDelegate); + try (RedisRoleCommandRouter lossRouter = + new RedisRoleCommandRouter( + RedisRole.CACHE, + lossRuntime, + 1, + 65_536, + 65_536, + Duration.ofSeconds(6), + Duration.ofSeconds(30))) { + RedisStringValuePrimitives lossStrings = catalog.strings(lossRouter); + RedisPrimitiveKey lossKey = lossStrings.key("atomic", "response-loss"); + RedisPrimitiveMutationResult lost = + lossStrings.set(lossKey, lossStrings.value("applied-once"), Duration.ofSeconds(5)); + assertThat(lost.status()).isEqualTo(RedisPrimitiveMutationResult.Status.UNKNOWN); + assertThat(lost.certainty()) + .isEqualTo(RedisPrimitiveMutationResult.Certainty.INDETERMINATE); + assertThat(lossRuntime.calls).isOne(); + assertThat(stringsB.get(lossKey).values().getFirst().copyEncoded()) + .isEqualTo("applied-once".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } + } + } + + @Test + void nineStructureFacadesReturnBinarySafeBoundedTypedResultsThroughCanonicalRouter() { + try (Harness harness = harness("structures"); + Pod cache = harness.pod(RedisRole.CACHE, "primitive-structures-cache"); + Pod coordination = + harness.pod(RedisRole.COORDINATION, "primitive-structures-coordination")) { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + Duration ttl = Duration.ofSeconds(10); + + RedisStringValuePrimitives strings = catalog.strings(cache.router); + RedisPrimitiveKey stringA = strings.key("structures", "string-a"); + RedisPrimitiveKey stringB = strings.key("structures", "string-b"); + RedisPrimitiveValue exactLimit = strings.value("x".repeat(16_000)); + assertThat(exactLimit.encodedLength()).isEqualTo(16_000); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> strings.value("x".repeat(16_001))) + .isInstanceOf(IllegalArgumentException.class); + strings.set(stringA, strings.value("alpha"), ttl); + strings.set(stringB, RedisPrimitiveValue.copyOf(new byte[] {0, 1, (byte) 0xff}, 16), ttl); + RedisPrimitiveReply mget = strings.multiGet(List.of(stringA, stringB)); + assertThat(mget.elements()).hasSize(2); + assertThat(mget.elements().get(1).value().orElseThrow().copyEncoded()) + .containsExactly(0, 1, (byte) 0xff); + + RedisCounterPrimitives counters = catalog.counters(coordination.router); + RedisCounterResult negative = + counters.increment(counters.key("structures", "signed"), -2, -10, 10, ttl); + assertThat(negative.value()).hasValue(-2); + + RedisHashPrimitives hashes = catalog.hashes(cache.router); + RedisPrimitiveKey hashKey = hashes.key("structures", "hash"); + assertThat(hashes.put(hashKey, hashes.field("field"), hashes.value("value"), ttl).status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.APPLIED); + assertThat(hashes.get(hashKey, hashes.field("field")).values().getFirst().copyEncoded()) + .isEqualTo("value".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + RedisPrimitiveDescriptor hashScan = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE); + RedisPrimitiveScanOutcome hashPage = + hashes.scan(hashKey, RedisPrimitiveCursor.initial(catalog, hashScan, hashKey, 1), 1); + assertThat(hashPage.page().orElseThrow().elements()).hasSize(1); + + RedisSetPrimitives sets = catalog.sets(cache.router); + RedisPrimitiveKey setKey = sets.key("structures", "set"); + sets.admit(setKey, sets.member("member"), ttl); + assertThat(sets.contains(setKey, sets.member("member")).status()) + .isEqualTo(RedisPrimitiveReply.Status.MEMBER); + + RedisSortedSetPrimitives sorted = catalog.sortedSets(cache.router); + RedisPrimitiveKey sortedKey = sorted.key("structures", "sorted"); + sorted.admitOrUpdate(sortedKey, sorted.member("one"), RedisSortedSetScore.of("1.5"), ttl); + sorted.admitOrUpdate(sortedKey, sorted.member("two"), RedisSortedSetScore.of("2"), ttl); + assertThat( + sorted + .count(sortedKey, RedisSortedSetScore.of("0"), RedisSortedSetScore.of("3")) + .signedNumber()) + .hasValue(2); + assertThat(sorted.rankPage(sortedKey, 0, 2).elements()).hasSize(2); + + RedisListPrimitives lists = catalog.lists(cache.router); + RedisPrimitiveKey listKey = lists.key("structures", "list"); + lists.admit(listKey, lists.value("first"), ttl); + assertThat(lists.pop(listKey).values().getFirst().copyEncoded()) + .isEqualTo("first".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + RedisBitmapPrimitives bitmaps = catalog.bitmaps(cache.router); + RedisPrimitiveKey bitmapKey = bitmaps.key("structures", "bitmap"); + RedisBitmapMutationResult firstSet = bitmaps.set(bitmapKey, bitmaps.offset(8), true); + assertThat(firstSet.previousBit()).hasValue(0); + assertThat( + bitmaps.count(bitmapKey, bitmaps.byteOffset(1), bitmaps.byteOffset(1)).signedNumber()) + .hasValue(1); + + RedisHyperLogLogPrimitives hll = catalog.hyperLogLogs(cache.router); + RedisPrimitiveKey hllKey = hll.key("structures", "hll"); + RedisPrimitiveKey hllSource = hll.key("structures", "hll-source"); + hll.add(hllKey, List.of(hll.element("one"), hll.element("two"))); + hll.add(hllSource, List.of(hll.element("three"))); + hll.merge(hllKey, List.of(hllSource)); + assertThat(hll.count(hllKey).signedNumber()).hasValue(3); + + RedisGeoPrimitives geo = catalog.geo(cache.router); + RedisPrimitiveKey geoKey = geo.key("structures", "geo"); + RedisGeoCoordinate center = new RedisGeoCoordinate(127.0, 37.5); + geo.admitOrUpdate(geoKey, geo.member("office"), center, ttl); + RedisPrimitiveReply radius = + geo.search(geoKey, center, 100, 5, RedisPrimitiveInvocation.GeoArguments.Sort.ASCENDING); + assertThat(radius.status()).isEqualTo(RedisPrimitiveReply.Status.PAGE); + assertThat(radius.values()).hasSize(2); + RedisPrimitiveReply box = + geo.searchBox( + geoKey, center, 100, 100, 5, RedisPrimitiveInvocation.GeoArguments.Sort.DESCENDING); + assertThat(box.signedNumber()).hasValue(1); + } + } + + @Test + void boundedScansHashCasAndPairedPagesEnforceTheFullProductionWireContracts() { + try (Harness harness = harness("boundary-wire"); + Pod cache = harness.pod(RedisRole.CACHE, "primitive-boundary-wire-cache")) { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + Duration ttl = Duration.ofSeconds(30); + RedisHashPrimitives hashes = catalog.hashes(cache.router); + RedisSetPrimitives sets = catalog.sets(cache.router); + RedisPrimitiveDescriptor hashScan = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE); + RedisPrimitiveDescriptor setScan = catalog.descriptor(RedisPrimitiveId.SET_SCAN_PAGE); + + RedisPrimitiveKey missingHash = hashes.key("boundary-wire", "missing-hash"); + RedisPrimitivePage missingHashPage = + hashes + .scan(missingHash, RedisPrimitiveCursor.initial(catalog, hashScan, missingHash, 1), 1) + .page() + .orElseThrow(); + assertThat(missingHashPage.elements()).isEmpty(); + assertThat(missingHashPage.complete()).isTrue(); + + RedisPrimitiveKey missingSet = sets.key("boundary-wire", "missing-set"); + RedisPrimitivePage missingSetPage = + sets.scan(missingSet, RedisPrimitiveCursor.initial(catalog, setScan, missingSet, 1), 1) + .page() + .orElseThrow(); + assertThat(missingSetPage.elements()).isEmpty(); + assertThat(missingSetPage.complete()).isTrue(); + + RedisPrimitiveKey fullHash = hashes.key("boundary-wire", "full-hash"); + for (int index = 0; index < 256; index++) { + assertThat( + hashes + .put( + fullHash, + hashes.field("field-" + index), + hashes.value("value-" + index), + ttl) + .status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.APPLIED); + } + RedisPrimitiveCursor cursor = RedisPrimitiveCursor.initial(catalog, hashScan, fullHash, 1); + HashSet observedFields = new HashSet<>(); + for (int pageIndex = 0; pageIndex < 257; pageIndex++) { + RedisPrimitivePage page = + hashes.scan(fullHash, cursor, 1).page().orElseThrow(); + page.elements() + .forEach( + entry -> + observedFields.add( + new String(entry.field().copyEncoded(), StandardCharsets.UTF_8))); + cursor = page.nextCursor(); + if (page.complete()) { + break; + } + } + assertThat(observedFields).hasSize(256); + + assertThat( + harness.appCommand("HSET", physicalKey(fullHash), "overflow-field", "overflow-value")) + .isEqualTo("1"); + RedisPrimitiveScanOutcome corruptHash = + hashes.scan(fullHash, RedisPrimitiveCursor.initial(catalog, hashScan, fullHash, 1), 1); + assertThat(corruptHash.status()).isEqualTo(RedisPrimitiveReply.Status.STATE_OVER_CAPACITY); + assertThat(corruptHash.page()).isEmpty(); + + RedisPrimitiveKey revisionOnly = hashes.key("boundary-wire", "revision-only"); + assertThat(harness.appCommand("HSET", physicalKey(revisionOnly), "_revision", "revision_1")) + .isEqualTo("1"); + assertThat(harness.appCommand("PEXPIRE", physicalKey(revisionOnly), "30000")).isEqualTo("1"); + assertThat( + hashes + .compareRevision( + revisionOnly, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.VALUE, + "revision_1", + "revision_2", + hashes.value("new-value"), + ttl) + .status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.CORRUPT); + + RedisPrimitiveKey valueOnly = hashes.key("boundary-wire", "value-only"); + assertThat(harness.appCommand("HSET", physicalKey(valueOnly), "value", "stored-value")) + .isEqualTo("1"); + assertThat(harness.appCommand("PEXPIRE", physicalKey(valueOnly), "30000")).isEqualTo("1"); + assertThat( + hashes + .compareRevision( + valueOnly, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.ABSENT, + "", + "revision_1", + hashes.value("new-value"), + ttl) + .status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.CORRUPT); + + RedisPrimitiveKey extraField = hashes.key("boundary-wire", "extra-field"); + assertThat( + hashes + .compareRevision( + extraField, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.ABSENT, + "", + "revision_1", + hashes.value("stored-value"), + ttl) + .status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.APPLIED); + assertThat(harness.appCommand("HSET", physicalKey(extraField), "extra", "corrupt")) + .isEqualTo("1"); + assertThat( + hashes + .compareRevision( + extraField, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.VALUE, + "revision_1", + "revision_2", + hashes.value("must-not-write"), + ttl) + .status()) + .isEqualTo(RedisPrimitiveMutationResult.Status.CORRUPT); + assertThat( + new String( + hashes.get(extraField, hashes.field("value")).values().getFirst().copyEncoded(), + StandardCharsets.UTF_8)) + .isEqualTo("stored-value"); + + RedisSortedSetPrimitives sorted = catalog.sortedSets(cache.router); + RedisPrimitiveKey sortedKey = sorted.key("boundary-wire", "full-sorted"); + for (int index = 0; index < 256; index++) { + sorted.admitOrUpdate( + sortedKey, + sorted.member("member-" + index), + RedisSortedSetScore.of(Integer.toString(index)), + ttl); + } + RedisPrimitiveReply scores = + sorted.scorePage( + sortedKey, RedisSortedSetScore.of("0"), RedisSortedSetScore.of("255"), 0, 256); + assertThat(scores.signedNumber()).hasValue(256); + assertThat(scores.values()).hasSize(512); + + RedisGeoPrimitives geo = catalog.geo(cache.router); + RedisPrimitiveKey geoKey = geo.key("boundary-wire", "full-geo"); + RedisGeoCoordinate center = new RedisGeoCoordinate(127.0, 37.5); + for (int index = 0; index < 256; index++) { + geo.admitOrUpdate(geoKey, geo.member("place-" + index), center, ttl); + } + RedisPrimitiveReply locations = + geo.search( + geoKey, center, 1_000, 256, RedisPrimitiveInvocation.GeoArguments.Sort.ASCENDING); + assertThat(locations.signedNumber()).hasValue(256); + assertThat(locations.values()).hasSize(512); + } + } + + private static String physicalKey(RedisPrimitiveKey key) { + return new String( + RedisPhysicalKey.WireCodec.copy(key.physicalKey()), StandardCharsets.US_ASCII); + } + + private Harness harness(String suffix) { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer( + image, materials.resolve(suffix), List.of("~ca:primitive:*"), commandPermissions()); + container.start(); + return new Harness(container); + } + + private static List commandPermissions() { + return List.of( + "+ping", + "+hello", + "+client|setname", + "+script|load", + "+evalsha", + "+get", + "+getrange", + "+strlen", + "+set", + "+del", + "+exists", + "+type", + "+pexpire", + "+pttl", + "+incrby", + "+hget", + "+hmget", + "+hset", + "+hdel", + "+hlen", + "+hexists", + "+hscan", + "+sadd", + "+sismember", + "+srem", + "+scard", + "+sscan", + "+zscore", + "+zadd", + "+zrem", + "+zcard", + "+zcount", + "+zrange", + "+zrangebyscore", + "+zremrangebyscore", + "+rpush", + "+rpop", + "+llen", + "+ltrim", + "+getbit", + "+setbit", + "+bitcount", + "+pfadd", + "+pfcount", + "+pfmerge", + "+geoadd", + "+geosearch"); + } + + private static void awaitMissing(java.util.function.Supplier read) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); + RedisPrimitiveReply reply; + do { + reply = read.get(); + if (reply.status() == RedisPrimitiveReply.Status.MISSING) { + return; + } + Thread.sleep(20); + } while (System.nanoTime() < deadline); + throw new AssertionError("primitive key did not expire: " + reply.status()); + } + + private static RedisClientRuntimeSettings runtimeSettings(String clientName) { + return new RedisClientRuntimeSettings( + clientName, + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 64, + 5, + Duration.ofSeconds(30)); + } + + private static final class Harness implements AutoCloseable { + + private final RedisTlsAclEvidenceContainer container; + + private Harness(RedisTlsAclEvidenceContainer container) { + this.container = container; + } + + private Pod pod(RedisRole role, String clientName) { + RedisTopologyCommandRuntime runtime = runtime(clientName); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + role, runtime, 64, 65_536, 4_194_304, Duration.ofSeconds(6), Duration.ofSeconds(30)); + return new Pod(runtime, router); + } + + private RedisTopologyCommandRuntime runtime(String clientName) { + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "primitive-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "primitive-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + return RedisTopologyCommandRuntime.connect( + new RedisDeploymentSettings.Standalone( + "primitive-" + clientName, + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication( + "app-user", "secret://evidence/redis-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/redis-ca")), + runtimeSettings(clientName), + 65_536, + credentials, + trust, + Clock.systemUTC()); + } + + private String appCommand(String... command) { + return container.executeAppCommand(command); + } + + @Override + public void close() { + container.close(); + } + } + + private record Pod(RedisTopologyCommandRuntime runtime, RedisRoleCommandRouter router) + implements AutoCloseable { + + @Override + public void close() { + router.close(); + } + } + + private static final class LossAfterApplyRuntime implements RedisRoutableCommandRuntime { + + private final RedisTopologyCommandRuntime delegate; + private int calls; + + private LossAfterApplyRuntime(RedisTopologyCommandRuntime delegate) { + this.delegate = delegate; + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + calls++; + delegate.execute(invocation); + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "simulated response loss after actual Redis mutation", + null); + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return delegate.get(key); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + delegate.set(key, value, timeToLive); + } + + @Override + public long delete(RedisPhysicalKey key) { + return delegate.delete(key); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return delegate.loadCatalogProgram(invocation); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return delegate.executeCatalogProgram(invocation); + } + + @Override + public String deploymentId() { + return delegate.deploymentId(); + } + + @Override + public void probe(Duration timeout) { + delegate.probe(timeout); + } + + @Override + public void close() { + delegate.close(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramScriptRecoveryEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramScriptRecoveryEvidenceTest.java new file mode 100644 index 0000000..2b88ecb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramScriptRecoveryEvidenceTest.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-standalone") +@Tag("card-redis-cache") +class RedisProgramScriptRecoveryEvidenceTest { + + @Test + void flushScriptIsRecoveredByScriptLoadThenEvalShaAgainstActualRedis() { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + try (RedisStandaloneEvidenceContainer container = new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = new byte[32]; + Arrays.fill(hmacSecret, (byte) 0x5a); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect(settings(container, hmacSecret)); + io.lettuce.core.RedisClient adminClient = + io.lettuce.core.RedisClient.create( + RedisURI.Builder.redis(container.host(), container.port()).build()); + StatefulRedisConnection admin = adminClient.connect()) { + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisProgramDescriptor descriptor = catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE); + RedisProgramDescriptor boundedGet = catalog.descriptor(RedisProgramId.BOUNDED_GET_V1); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, runtime); + byte[] key = "ca:test:{script-recovery}:owner".getBytes(US_ASCII); + byte[] boundedKey = "ca:test:{script-recovery}:bounded".getBytes(US_ASCII); + byte[] boundedValue = "bounded-value".getBytes(US_ASCII); + List arguments = List.of("owner-token".getBytes(US_ASCII)); + String sha1 = RedisScriptRecovery.sha1(descriptor.scriptBytes()); + String boundedGetSha1 = RedisScriptRecovery.sha1(boundedGet.scriptBytes()); + + runtime.set( + RedisPhysicalKeyTestFactory.fromEncoded(boundedKey), + RedisBinaryValue.encoded(boundedValue), + Duration.ofMinutes(1)); + assertThat( + executor.execute( + RedisProgramTestInvocations.scalar( + catalog, descriptor.id(), List.of(key), arguments))) + .isEqualTo("ABSENT"); + assertThat(runtime.get(RedisPhysicalKeyTestFactory.fromEncoded(boundedKey))) + .isEqualTo(boundedValue); + assertThat(admin.sync().scriptExists(sha1)).containsExactly(true); + assertThat(admin.sync().scriptExists(boundedGetSha1)).containsExactly(true); + + admin.sync().scriptFlush(); + assertThat(admin.sync().scriptExists(sha1)).containsExactly(false); + assertThat(admin.sync().scriptExists(boundedGetSha1)).containsExactly(false); + + assertThat( + executor.execute( + RedisProgramTestInvocations.scalar( + catalog, descriptor.id(), List.of(key), arguments))) + .isEqualTo("ABSENT"); + assertThat(runtime.get(RedisPhysicalKeyTestFactory.fromEncoded(boundedKey))) + .isEqualTo(boundedValue); + assertThat(admin.sync().scriptExists(sha1)).containsExactly(true); + assertThat(admin.sync().scriptExists(boundedGetSha1)).containsExactly(true); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + private static RedisRuntimeSettings settings( + RedisStandaloneEvidenceContainer container, byte[] hmacSecret) { + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + container.host(), + container.port(), + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofSeconds(2), + Duration.ofSeconds(2), + Duration.ofSeconds(1), + Duration.ofSeconds(1), + 0.0, + Duration.ofMillis(10), + "ca-skeleton", + "qualification", + "script-recovery-evidence", + 128, + 8, + 1_048_576); + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java new file mode 100644 index 0000000..ccb3b34 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.lang.reflect.Constructor; +import java.util.List; + +/** Evidence-test-only reflective access to closed capability material. */ +final class RedisProgramTestInvocations { + + private RedisProgramTestInvocations() {} + + static RedisCatalogProgramInvocation scalar( + RedisProgramCatalog catalog, RedisProgramId id, List keys, List arguments) { + try { + Constructor constructor = + RedisAtomicPrimitives.ProgramMaterial.class.getDeclaredConstructor( + RedisProgramId.class, List.class, List.class); + constructor.setAccessible(true); + return catalog.capabilityInvocation(constructor.newInstance(id, keys, arguments)); + } catch (ReflectiveOperationException exception) { + throw new LinkageError("cannot construct closed evidence program material", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java new file mode 100644 index 0000000..63fa3e1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitEvidenceTest.java @@ -0,0 +1,308 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("card-redis-edge-rate-limit") +class RedisRateLimitEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + @Tag("redis-standalone") + void allAlgorithmsEnforceQuotaAndDeduplicateAResponseLossRetryOnRealRedis() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(images.requiredImage("redis.minimum.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect(connection(container.host(), container.port(), hmacSecret))) { + try (RedisEdgeRateLimitProvider provider = provider(runtime, hmacSecret)) { + qualifyAlgorithms(provider); + } + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-security") + void namedAclAndExplicitTlsTrustProtectRateLimitPrograms(@TempDir java.nio.file.Path materials) { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer(images.requiredImage("redis.minimum.image"), materials)) { + container.start(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "rate-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "rate-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + byte[] hmacSecret = randomSecret(); + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + secureDeployment(container), + runtimeSettings(), + 65_536, + credentials, + trust, + Clock.systemUTC())) { + try (RedisEdgeRateLimitProvider provider = provider(runtime, hmacSecret)) { + RateLimitOutcome outcome = + provider.evaluate(request("fixed", subject(), "ev1:AAAAAAAAAAAAAAAAAAAAAA")); + assertThat(outcome).isInstanceOf(RateLimitOutcome.Evaluated.class); + } + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-fault") + void partitionFailsClosedWithExplicitCertaintyAndRecovers() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisToxiproxyEvidenceContainer container = + new RedisToxiproxyEvidenceContainer( + images.requiredImage("redis.minimum.image"), images.requiredImage("toxiproxy.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect(connection(container.host(), container.port(), hmacSecret))) { + try (RedisEdgeRateLimitProvider provider = provider(runtime, hmacSecret)) { + assertThat(provider.evaluate(request("fixed", subject(), "ev1:BBBBBBBBBBBBBBBBBBBBBB"))) + .isInstanceOf(RateLimitOutcome.Evaluated.class); + + container.disableProxy(); + RateLimitOutcome failed = + await( + () -> + provider.evaluate(request("fixed", subject(), "ev1:CCCCCCCCCCCCCCCCCCCCCC")), + outcome -> !(outcome instanceof RateLimitOutcome.Evaluated), + Duration.ofSeconds(5)); + assertThat(failed) + .isInstanceOfAny( + RateLimitOutcome.Unavailable.class, RateLimitOutcome.Indeterminate.class); + + container.enableProxy(); + RateLimitOutcome recovered = + await( + () -> + provider.evaluate(request("fixed", subject(), "ev1:DDDDDDDDDDDDDDDDDDDDDD")), + outcome -> outcome instanceof RateLimitOutcome.Evaluated, + Duration.ofSeconds(10)); + assertThat(recovered).isInstanceOf(RateLimitOutcome.Evaluated.class); + } + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-compatibility") + void rateProgramsRunAcrossPinnedSupportedRedisVersions() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + LinkedHashSet supportedImages = new LinkedHashSet<>(); + supportedImages.add(images.requiredImage("redis.minimum.image")); + supportedImages.add(images.requiredImage("redis.next-minor.image")); + supportedImages.add(images.requiredImage("redis.approved.image")); + + for (String image : supportedImages) { + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret))) { + try (RedisEdgeRateLimitProvider provider = provider(runtime, hmacSecret)) { + qualifyAlgorithms(provider); + } + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + } + + private static void qualifyAlgorithms(RedisEdgeRateLimitProvider provider) { + int index = 0; + for (String policyId : List.of("fixed", "sliding", "token")) { + String subject = subject(); + String evaluationId = "ev1:" + String.valueOf((char) ('A' + index++)).repeat(22); + RateLimitRequest request = request(policyId, subject, evaluationId); + RateLimitOutcome first = provider.evaluate(request); + assertThat(first).isInstanceOf(RateLimitOutcome.Evaluated.class); + assertThat(decision(first).allowed()).isTrue(); + assertThat(decision(first).remaining()).isEqualTo(1); + assertThat(provider.evaluate(request)).isEqualTo(first); + + RateLimitOutcome denied = + provider.evaluate( + request(policyId, subject, "ev1:" + String.valueOf((char) ('K' + index)).repeat(22))); + assertThat(denied).isInstanceOf(RateLimitOutcome.Evaluated.class); + assertThat(decision(denied).allowed()).isFalse(); + assertThat(decision(denied).remaining()).isEqualTo(1); + } + } + + private static RedisEdgeRateLimitProvider provider( + RedisStructuredCommands commands, byte[] hmacSecret) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + return new RedisEdgeRateLimitProvider( + policies(), + catalog, + new RedisStructuredProgramExecutor(catalog, commands), + "ca-skeleton", + "qualification", + 1, + 1, + hmacSecret, + Clock.systemUTC(), + Duration.ofMillis(100), + Duration.ofMillis(10)); + } + + private static Map policies() { + return Map.of( + "fixed", + policy( + "fixed", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(3, Duration.ofMinutes(1))), + "sliding", + policy( + "sliding", + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(3, Duration.ofMinutes(1))), + "token", + policy( + "token", + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(3, 1, Duration.ofMinutes(1)))); + } + + private static RateLimitPolicy policy( + String id, RateLimitAlgorithm algorithm, RateParameters parameters) { + return new RateLimitPolicy( + id, + "evidence-v1", + algorithm, + parameters, + 2, + Duration.ofSeconds(5), + Duration.ofSeconds(1), + RateLimitFailurePolicy.FAIL_CLOSED); + } + + private static RateLimitRequest request(String policyId, String subject, String evaluationId) { + return new RateLimitRequest(policyId, subject, 2, evaluationId, Instant.now().plusSeconds(5)); + } + + private static RateLimitDecision decision(RateLimitOutcome outcome) { + return ((RateLimitOutcome.Evaluated) outcome).decision(); + } + + private static String subject() { + return "subject:" + UUID.randomUUID().toString().replace("-", ""); + } + + private static RedisLegacyStandaloneSettings connection( + String host, int port, byte[] hmacSecret) { + return new RedisLegacyStandaloneSettings( + host, + port, + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofMillis(300), + 65_536, + 32, + 1_048_576, + "ca-skeleton", + "qualification"); + } + + private static RedisDeploymentSettings.Standalone secureDeployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "rate-security-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication("app-user", "secret://evidence/rate-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/rate-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "rate-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } + + private static byte[] randomSecret() { + byte[] value = new byte[32]; + RANDOM.nextBytes(value); + return value; + } + + private static T await( + Supplier supplier, java.util.function.Predicate condition, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + T result = supplier.get(); + while (!condition.test(result) && System.nanoTime() < deadline) { + try { + Thread.sleep(25); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("rate-limit evidence wait was interrupted", exception); + } + result = supplier.get(); + } + return result; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java new file mode 100644 index 0000000..7a5d59c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessSecurityEvidenceTest.java @@ -0,0 +1,290 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("redis-security") +class RedisSemanticReadinessSecurityEvidenceTest { + + @TempDir Path materialDirectory; + + @Test + void namedCoordinationUserCanPingButDeniedScriptLoadMakesRequiredRoleUnavailable() { + assertRequiredRoleUnavailable( + RedisRole.COORDINATION, + Capability.IDEMPOTENCY, + materialDirectory.resolve("coord-script-load-denied"), + List.of("+ping", "+hello", "+client|setname", "+get", "+set", "+del", "+evalsha")); + } + + @Test + void namedCoordinationUserCanLoadScriptsButDeniedProgramHsetMakesRequiredRoleUnavailable() { + assertRequiredRoleUnavailable( + RedisRole.COORDINATION, + Capability.IDEMPOTENCY, + materialDirectory.resolve("coord-hset-denied"), + List.of( + "+ping", + "+hello", + "+client|setname", + "+get", + "+set", + "+del", + "+evalsha", + "+script|load", + "+type", + "+time", + "+hmget", + "+hdel", + "+pexpire")); + } + + @Test + void namedSessionUserCanPingButDeniedScriptLoadMakesRequiredRoleUnavailable() { + assertRequiredRoleUnavailable( + RedisRole.SESSION, + Capability.SESSION, + materialDirectory.resolve("session-script-load-denied"), + List.of("+ping", "+hello", "+client|setname", "+get", "+set", "+del", "+evalsha")); + } + + @Test + void rateAuxiliaryHashKeyPatternDenialFailsTheExactRepresentativeSurface() { + assertDirectProbe( + RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"), + RedisRole.COORDINATION, + Capability.RATE_LIMIT, + materialDirectory.resolve("rate-aux-key-denied"), + List.of("~ca-health:*:rw", "~ca-health:*:p0-k0", "~ca-health:*:p0-k2"), + RedisTlsAclEvidenceContainer.defaultCommandPermissions(), + false, + Reason.SEMANTIC_PROGRAM_ACL_DENIED); + } + + @Test + void rateOrderingZsetKeyPatternDenialFailsTheExactRepresentativeSurface() { + assertDirectProbe( + RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"), + RedisRole.COORDINATION, + Capability.RATE_LIMIT, + materialDirectory.resolve("rate-ordering-key-denied"), + List.of("~ca-health:*:rw", "~ca-health:*:p0-k0", "~ca-health:*:p0-k1"), + RedisTlsAclEvidenceContainer.defaultCommandPermissions(), + false, + Reason.SEMANTIC_PROGRAM_ACL_DENIED); + } + + @Test + void sessionTombstoneKeyPatternDenialFailsTheExactRepresentativeSurface() { + assertDirectProbe( + RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"), + RedisRole.SESSION, + Capability.SESSION, + materialDirectory.resolve("session-tombstone-key-denied"), + List.of("~ca-health:*:rw", "~ca-health:*:p0-k0"), + RedisTlsAclEvidenceContainer.defaultCommandPermissions(), + false, + Reason.SEMANTIC_PROGRAM_ACL_DENIED); + } + + @Test + void warmRepresentativeAndAclScriptsStillProveRuntimeScriptLoadDenial() { + List permissions = + RedisTlsAclEvidenceContainer.defaultCommandPermissions().stream() + .filter(permission -> !permission.equals("+script|load")) + .toList(); + assertDirectProbe( + RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"), + RedisRole.CACHE, + Capability.CACHE, + materialDirectory.resolve("warm-script-load-denied"), + List.of(RedisSemanticProbePlan.ACL_KEY_PATTERN), + permissions, + true, + Reason.SEMANTIC_PROGRAM_ACL_DENIED); + } + + @Test + void pinnedMinimumMinusOneIsRejectedAndPinnedMinimumSucceeds() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + assertDirectProbe( + images.requiredImage("redis.below-minimum.image"), + RedisRole.CACHE, + Capability.CACHE, + materialDirectory.resolve("version-7-0"), + List.of(RedisSemanticProbePlan.ACL_KEY_PATTERN), + RedisTlsAclEvidenceContainer.defaultCommandPermissions(), + false, + Reason.SERVER_VERSION_UNSUPPORTED); + assertDirectProbe( + images.requiredImage("redis.minimum.image"), + RedisRole.CACHE, + Capability.CACHE, + materialDirectory.resolve("version-7-2"), + List.of(RedisSemanticProbePlan.ACL_KEY_PATTERN), + RedisTlsAclEvidenceContainer.defaultCommandPermissions(), + false, + Reason.SEMANTIC_PROBE_SUCCEEDED); + } + + private static void assertRequiredRoleUnavailable( + RedisRole role, Capability capability, Path materials, List commandPermissions) { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer( + image, + materials, + List.of(RedisSemanticProbePlan.ACL_KEY_PATTERN), + commandPermissions)) { + container.start(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "semantic-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "semantic-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + RedisClientRuntimeSettings clientSettings = runtimeSettings(); + + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + deployment(container), + clientSettings, + 65_536, + credentials, + trust, + Clock.systemUTC())) { + assertThatThrownBy( + () -> + new RedisCanonicalRoleRegistry( + Map.of(role, deployment(container)), + clientSettings, + 8, + 65_536, + 1_048_576, + Duration.ofSeconds(6), + Duration.ofSeconds(5), + ignoredDeployment -> runtime, + Map.of( + role, + new RedisRoleBinding( + "semantic-readiness-evidence", true, "noeviction")), + Map.of(role, Set.of(capability)), + Clock.systemUTC())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(Reason.SEMANTIC_PROGRAM_ACL_DENIED.name()) + .hasMessageNotContaining("NOPERM") + .hasMessageNotContaining("app-user") + .hasMessageNotContaining(container.host()) + .hasMessageNotContaining(String.valueOf(container.port())) + .hasMessageNotContaining(RedisSemanticProbePlan.KEY_NAMESPACE_PREFIX); + } + } + } + + private static void assertDirectProbe( + String image, + RedisRole role, + Capability capability, + Path materials, + List keyPatterns, + List commandPermissions, + boolean preload, + Reason expected) { + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer(image, materials, keyPatterns, commandPermissions)) { + container.start(); + if (preload) { + RedisProgramDescriptor descriptor = + RedisProgramCatalog.unified() + .descriptor( + RedisSemanticProbePlan.forRole(role, Set.of(capability)) + .representativePrograms() + .getFirst()); + container.preloadScripts( + descriptor.scriptBytes(), RedisSemanticAclProbeCatalog.scriptBytes()); + } + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "semantic-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "semantic-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + deployment(container), + runtimeSettings(), + 65_536, + credentials, + trust, + Clock.systemUTC()); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + runtime, 8, 65_536, 1_048_576, Duration.ofSeconds(6), Duration.ofSeconds(5))) { + Reason reason = + RedisSemanticReadinessProbe.system(Clock.systemUTC()) + .probe(RedisSemanticProbePlan.forRole(role, Set.of(capability)), router); + assertThat(reason).isEqualTo(expected); + } + assertThat(container.probeKeyCount()).isZero(); + } + } + + private static RedisDeploymentSettings.Standalone deployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "semantic-readiness-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication("app-user", "secret://evidence/redis-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/redis-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "semantic-readiness-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java new file mode 100644 index 0000000..729ed44 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEvidenceTest.java @@ -0,0 +1,383 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Supplier; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("card-redis-session") +class RedisSessionEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + @Tag("redis-standalone") + void twoIndependentPodsShareTouchAndLogoutDominatesAConcurrentStaleSave() throws Exception { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + try (RedisStandaloneEvidenceContainer container = new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (SessionPod podA = pod(container.host(), container.port(), hmacSecret); + SessionPod podB = pod(container.host(), container.port(), hmacSecret); + var executor = Executors.newFixedThreadPool(2)) { + RedisVersionedSession created = podA.repository.createSession(); + created.setAttribute("principal", "multi-pod-user"); + podA.repository.save(created); + RedisVersionedSession stale = podA.repository.findById(created.getId()); + RedisVersionedSession otherPod = podB.repository.findById(created.getId()); + assertThat(otherPod.getAttribute("principal")).isEqualTo("multi-pod-user"); + + CountDownLatch start = new CountDownLatch(1); + Future logout = + executor.submit( + () -> { + await(start); + podB.repository.deleteById(created.getId()); + }); + Future staleSave = + executor.submit( + () -> { + await(start); + stale.setAttribute("role", "must-not-resurrect"); + try { + podA.repository.save(stale); + return null; + } catch (RuntimeException exception) { + return exception; + } + }); + start.countDown(); + logout.get(); + RuntimeException staleOutcome = staleSave.get(); + + assertThat(podA.repository.findById(created.getId())).isNull(); + podB.repository.deleteById(created.getId()); + if (staleOutcome != null) { + assertThat(staleOutcome).isInstanceOf(RedisSessionConflictException.class); + } + assertThatThrownBy(() -> podA.repository.save(stale)) + .isInstanceOf(RedisSessionConflictException.class) + .hasMessageContaining("TOMBSTONED"); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-security") + void tlsExplicitTrustAndNamedAclProtectOnlyTheSessionNamespace(@TempDir Path materials) { + String image = RedisEvidenceImageRegistry.load().requiredImage("redis.minimum.image"); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer( + image, materials, List.of("~ca:ca-skeleton:qualification:session:*"))) { + container.start(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "session-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "session-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + byte[] hmacSecret = randomSecret(); + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + secureDeployment(container), + runtimeSettings(), + 65_536, + credentials, + trust, + Clock.systemUTC()); + RedisLuaVersionedSessionStore store = sessionStore(runtime, hmacSecret)) { + RedisVersionedSessionRepository repository = sessionRepository(store); + RedisVersionedSession session = repository.createSession(); + session.setAttribute("principal", "tls-acl-user"); + repository.save(session); + assertThat(repository.findById(session.getId()).getAttribute("principal")) + .isEqualTo("tls-acl-user"); + + assertThatThrownBy( + () -> + runtime.set( + RedisPhysicalKeyTestFactory.fromEncoded( + "ca:ca-skeleton:qualification:cache:forbidden".getBytes(US_ASCII)), + RedisBinaryValue.encoded("cross-role".getBytes(US_ASCII)), + Duration.ofSeconds(5))) + .isInstanceOf(RedisCommandFailureException.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-fault") + void partitionAndNoEvictionOomFailClosedThenRecoverWithoutInventingASession() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + byte[] partitionSecret = randomSecret(); + try (RedisToxiproxyEvidenceContainer container = + new RedisToxiproxyEvidenceContainer( + images.requiredImage("redis.minimum.image"), images.requiredImage("toxiproxy.image"))) { + container.start(); + try (SessionPod pod = pod(container.host(), container.port(), partitionSecret)) { + RedisVersionedSession session = pod.repository.createSession(); + session.setAttribute("principal", "fault-user"); + pod.repository.save(session); + + container.disableProxy(); + assertThat( + awaitUnavailable( + () -> pod.repository.findById(session.getId()), Duration.ofSeconds(5))) + .isTrue(); + + container.enableProxy(); + RedisVersionedSession recovered = + awaitLive(() -> pod.repository.findById(session.getId()), Duration.ofSeconds(10)); + assertThat(recovered.getAttribute("principal")).isEqualTo("fault-user"); + } finally { + Arrays.fill(partitionSecret, (byte) 0); + } + } + + byte[] oomSecret = randomSecret(); + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer( + images.requiredImage("redis.minimum.image"), + "redis-server", + "--save", + "", + "--appendonly", + "no", + "--maxmemory", + "512kb", + "--maxmemory-policy", + "noeviction")) { + container.start(); + try (SessionPod pod = pod(container.host(), container.port(), oomSecret)) { + RedisVersionedSession rejected = pod.repository.createSession(); + rejected.setAttribute("principal", "must-not-be-acknowledged"); + assertThatThrownBy(() -> pod.repository.save(rejected)) + .isInstanceOf(RedisSessionUnavailableException.class); + assertThat(pod.repository.findById(rejected.getId())).isNull(); + + removeMemoryLimit(container.host(), container.port()); + pod.repository.save(rejected); + assertThat(pod.repository.findById(rejected.getId())).isNotNull(); + } finally { + Arrays.fill(oomSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-compatibility") + void sessionCreateRotateAndLogoutRunAcrossPinnedSupportedRedisVersions() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + LinkedHashSet supportedImages = new LinkedHashSet<>(); + supportedImages.add(images.requiredImage("redis.minimum.image")); + supportedImages.add(images.requiredImage("redis.next-minor.image")); + supportedImages.add(images.requiredImage("redis.approved.image")); + + for (String image : supportedImages) { + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (SessionPod pod = pod(container.host(), container.port(), hmacSecret)) { + RedisVersionedSession session = pod.repository.createSession(); + session.setAttribute("principal", "compatibility-user"); + pod.repository.save(session); + String oldId = session.getId(); + String newId = session.changeSessionId(); + pod.repository.save(session); + + assertThat(pod.repository.findById(oldId)).isNull(); + assertThat(pod.repository.findById(newId)).isNotNull(); + pod.repository.deleteById(newId); + assertThat(pod.repository.findById(newId)).isNull(); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + } + + private static SessionPod pod(String host, int port, byte[] hmacSecret) { + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(connection(host, port, hmacSecret)); + try { + RedisLuaVersionedSessionStore store = sessionStore(runtime, hmacSecret); + return new SessionPod(runtime, store, sessionRepository(store)); + } catch (RuntimeException exception) { + runtime.close(); + throw exception; + } + } + + private static RedisLuaVersionedSessionStore sessionStore( + RedisStructuredCommands commands, byte[] hmacSecret) { + return new RedisLuaVersionedSessionStore( + commands, "ca-skeleton", "qualification", 1, 1, hmacSecret); + } + + private static RedisVersionedSessionRepository sessionRepository( + VersionedRedisSessionStore store) { + return new RedisVersionedSessionRepository( + store, + new RedisSessionEnvelopeCodec(32_768, 64, 8_192), + Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(30), + Duration.ofMillis(100), + Duration.ofSeconds(10)); + } + + private static RedisLegacyStandaloneSettings connection( + String host, int port, byte[] hmacSecret) { + return new RedisLegacyStandaloneSettings( + host, + port, + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofMillis(500), + 65_536, + 32, + 1_048_576, + "ca-skeleton", + "qualification"); + } + + private static RedisDeploymentSettings.Standalone secureDeployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "session-security-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication( + "app-user", "secret://evidence/session-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/session-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "session-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } + + private static boolean awaitUnavailable(Supplier operation, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + do { + try { + Object availableResult = operation.get(); + if (availableResult != null) { + return false; + } + } catch (RedisSessionUnavailableException exception) { + return true; + } + } while (System.nanoTime() < deadline); + return false; + } + + private static RedisVersionedSession awaitLive( + Supplier operation, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + do { + try { + RedisVersionedSession value = operation.get(); + if (value != null) { + return value; + } + } catch (RedisSessionUnavailableException ignored) { + // Connection auto-recovery is bounded by this explicit evidence deadline. + } + } while (System.nanoTime() < deadline); + throw new AssertionError("Redis session did not recover before the evidence deadline"); + } + + private static void removeMemoryLimit(String host, int port) { + try (RedisClient client = RedisClient.create(RedisURI.Builder.redis(host, port).build()); + StatefulRedisConnection connection = client.connect()) { + assertThat(connection.sync().configSet("maxmemory", "0")).isEqualTo("OK"); + } + } + + private static byte[] randomSecret() { + byte[] value = new byte[32]; + RANDOM.nextBytes(value); + return value; + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("session evidence race was interrupted", exception); + } + } + + private static final class SessionPod implements AutoCloseable { + + private final LettuceRedisRuntime runtime; + private final RedisLuaVersionedSessionStore store; + private final RedisVersionedSessionRepository repository; + + private SessionPod( + LettuceRedisRuntime runtime, + RedisLuaVersionedSessionStore store, + RedisVersionedSessionRepository repository) { + this.runtime = runtime; + this.store = store; + this.repository = repository; + } + + @Override + public void close() { + try { + store.close(); + } finally { + runtime.close(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java new file mode 100644 index 0000000..1fbae1f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSoftLeaseEvidenceTest.java @@ -0,0 +1,259 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.application.cache.CacheRefreshClaimAttempt; +import dev.caskeleton.application.cache.CacheRefreshClaimOutcome; +import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag("card-redis-cache-refresh-soft-lease") +class RedisSoftLeaseEvidenceTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + @Tag("redis-standalone") + void twoPodsSuppressDuplicateRefreshAndExpiredLeaseCanBeReclaimed() throws InterruptedException { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(images.requiredImage("redis.minimum.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime firstRuntime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + LettuceRedisRuntime secondRuntime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisCacheRefreshCoordinator first = coordinator(firstRuntime, hmacSecret); + RedisCacheRefreshCoordinator second = coordinator(secondRuntime, hmacSecret)) { + CacheRefreshClaimAttempt firstAttempt = first.newAttempt(); + CacheRefreshClaimAttempt secondAttempt = second.newAttempt(); + assertThat(first.claim("worklog-1", firstAttempt, Duration.ofSeconds(1))) + .isInstanceOf(CacheRefreshClaimOutcome.Claimed.class); + assertThat(second.claim("worklog-1", secondAttempt, Duration.ofSeconds(1))) + .isInstanceOf(CacheRefreshClaimOutcome.Contended.class); + assertThat(first.release("worklog-1", firstAttempt)) + .isInstanceOf(CacheRefreshReleaseOutcome.Released.class); + assertThat(second.claim("worklog-1", secondAttempt, Duration.ofMillis(100))) + .isInstanceOf(CacheRefreshClaimOutcome.Claimed.class); + + Thread.sleep(160); + CacheRefreshClaimAttempt reclaimed = first.newAttempt(); + assertThat(first.claim("worklog-1", reclaimed, Duration.ofSeconds(1))) + .isInstanceOf(CacheRefreshClaimOutcome.Claimed.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-security") + void namedAclAndExplicitTlsTrustProtectRefreshLeasePrograms( + @TempDir java.nio.file.Path materials) { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisTlsAclEvidenceContainer container = + new RedisTlsAclEvidenceContainer(images.requiredImage("redis.minimum.image"), materials)) { + container.start(); + RedisCredentialMaterialProvider credentials = + reference -> + new VersionedRedisCredentialMaterial( + "soft-lease-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisSecret.from(container.password())); + RedisTrustMaterialProvider trust = + reference -> + new VersionedRedisTrustMaterial( + "soft-lease-evidence-v1", + Instant.now().plusSeconds(300), + DestroyableRedisPem.from(container.trustPem())); + byte[] hmacSecret = randomSecret(); + try (RedisTopologyCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + secureDeployment(container), + runtimeSettings(), + 65_536, + credentials, + trust, + Clock.systemUTC()); + RedisCacheRefreshCoordinator coordinator = coordinator(runtime, hmacSecret)) { + CacheRefreshClaimAttempt attempt = coordinator.newAttempt(); + assertThat(coordinator.claim("worklog-security", attempt, Duration.ofSeconds(1))) + .isInstanceOf(CacheRefreshClaimOutcome.Claimed.class); + assertThat(coordinator.release("worklog-security", attempt)) + .isInstanceOf(CacheRefreshReleaseOutcome.Released.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-fault") + void partitionExposesUncertainAdmissionAndSameAttemptReconcilesAfterRecovery() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + try (RedisToxiproxyEvidenceContainer container = + new RedisToxiproxyEvidenceContainer( + images.requiredImage("redis.minimum.image"), images.requiredImage("toxiproxy.image"))) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisCacheRefreshCoordinator coordinator = coordinator(runtime, hmacSecret)) { + CacheRefreshClaimAttempt attempt = coordinator.newAttempt(); + container.disableProxy(); + CacheRefreshClaimOutcome failed = + await( + () -> coordinator.claim("worklog-fault", attempt, Duration.ofSeconds(1)), + outcome -> + outcome instanceof CacheRefreshClaimOutcome.Unavailable + || outcome instanceof CacheRefreshClaimOutcome.Indeterminate, + Duration.ofSeconds(5)); + assertThat(failed) + .isInstanceOfAny( + CacheRefreshClaimOutcome.Unavailable.class, + CacheRefreshClaimOutcome.Indeterminate.class); + + container.enableProxy(); + CacheRefreshClaimOutcome reconciled = + await( + () -> coordinator.claim("worklog-fault", attempt, Duration.ofSeconds(1)), + outcome -> + outcome instanceof CacheRefreshClaimOutcome.Claimed + || outcome instanceof CacheRefreshClaimOutcome.AlreadyOwned, + Duration.ofSeconds(10)); + assertThat(reconciled) + .isInstanceOfAny( + CacheRefreshClaimOutcome.Claimed.class, + CacheRefreshClaimOutcome.AlreadyOwned.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + + @Test + @Tag("redis-compatibility") + void softLeaseProgramsRunAcrossPinnedSupportedRedisVersions() { + RedisEvidenceImageRegistry images = RedisEvidenceImageRegistry.load(); + LinkedHashSet supportedImages = new LinkedHashSet<>(); + supportedImages.add(images.requiredImage("redis.minimum.image")); + supportedImages.add(images.requiredImage("redis.next-minor.image")); + supportedImages.add(images.requiredImage("redis.approved.image")); + + for (String image : supportedImages) { + try (RedisStandaloneEvidenceContainer container = + new RedisStandaloneEvidenceContainer(image)) { + container.start(); + byte[] hmacSecret = randomSecret(); + try (LettuceRedisRuntime runtime = + LettuceRedisRuntime.connect( + connection(container.host(), container.port(), hmacSecret)); + RedisCacheRefreshCoordinator coordinator = coordinator(runtime, hmacSecret)) { + CacheRefreshClaimAttempt attempt = coordinator.newAttempt(); + assertThat(coordinator.claim("worklog-compatibility", attempt, Duration.ofSeconds(1))) + .isInstanceOf(CacheRefreshClaimOutcome.Claimed.class); + assertThat(coordinator.release("worklog-compatibility", attempt)) + .isInstanceOf(CacheRefreshReleaseOutcome.Released.class); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + } + } + + private static RedisCacheRefreshCoordinator coordinator( + RedisBinaryCommands commands, byte[] hmacSecret) { + return new RedisCacheRefreshCoordinator(namespace(), hmacSecret, commands); + } + + private static RedisKeyNamespace namespace() { + return new RedisKeyNamespace( + "ca-skeleton", "qualification", "cache", "soft-lease", 1, 1, "entry", 512); + } + + private static RedisLegacyStandaloneSettings connection( + String host, int port, byte[] hmacSecret) { + return new RedisLegacyStandaloneSettings( + host, + port, + "", + Base64.getEncoder().encodeToString(hmacSecret), + Duration.ofMillis(300), + 65_536, + 32, + 1_048_576, + "ca-skeleton", + "qualification"); + } + + private static RedisDeploymentSettings.Standalone secureDeployment( + RedisTlsAclEvidenceContainer container) { + return new RedisDeploymentSettings.Standalone( + "soft-lease-security-evidence", + 0, + List.of(new RedisDeploymentSettings.Endpoint(container.host(), container.port())), + new RedisDeploymentSettings.Authentication( + "app-user", "secret://evidence/soft-lease-password"), + new RedisDeploymentSettings.Tls(true, true, "secret://evidence/soft-lease-ca")); + } + + private static RedisClientRuntimeSettings runtimeSettings() { + return new RedisClientRuntimeSettings( + "soft-lease-security", + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 32, + 5, + Duration.ofSeconds(30)); + } + + private static byte[] randomSecret() { + byte[] value = new byte[32]; + RANDOM.nextBytes(value); + return value; + } + + private static T await(Supplier supplier, Predicate condition, Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + T result = supplier.get(); + while (!condition.test(result) && System.nanoTime() < deadline) { + try { + Thread.sleep(25); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("soft-lease evidence wait was interrupted", exception); + } + result = supplier.get(); + } + return result; + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStandaloneEvidenceContainer.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStandaloneEvidenceContainer.java new file mode 100644 index 0000000..3c8b253 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStandaloneEvidenceContainer.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.time.Duration; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +final class RedisStandaloneEvidenceContainer implements AutoCloseable { + + private final GenericContainer container; + + RedisStandaloneEvidenceContainer(String image) { + this(image, new String[0]); + } + + RedisStandaloneEvidenceContainer(String image, String... command) { + GenericContainer configured = + new GenericContainer<>(DockerImageName.parse(image)) + .withExposedPorts(6379) + .withStartupTimeout(Duration.ofSeconds(60)); + if (command.length > 0) { + configured.withCommand(command); + } + container = configured; + } + + void start() { + container.start(); + } + + String host() { + return container.getHost(); + } + + int port() { + return container.getMappedPort(6379); + } + + @Override + public void close() { + container.close(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTlsAclEvidenceContainer.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTlsAclEvidenceContainer.java new file mode 100644 index 0000000..a5a2fd8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTlsAclEvidenceContainer.java @@ -0,0 +1,379 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +final class RedisTlsAclEvidenceContainer implements AutoCloseable { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final Path materialDirectory; + private final char[] password; + private final char[] loaderPassword; + private final char[] evidencePassword; + private final List keyPatterns; + private final List commandPermissions; + private final GenericContainer container; + + RedisTlsAclEvidenceContainer(String image, Path materialDirectory) { + this(image, materialDirectory, List.of("~*")); + } + + RedisTlsAclEvidenceContainer(String image, Path materialDirectory, List keyPatterns) { + this(image, materialDirectory, keyPatterns, defaultCommandPermissions()); + } + + RedisTlsAclEvidenceContainer( + String image, + Path materialDirectory, + List keyPatterns, + List commandPermissions) { + this.materialDirectory = materialDirectory.toAbsolutePath().normalize(); + this.keyPatterns = validateKeyPatterns(keyPatterns); + this.commandPermissions = validateCommandPermissions(commandPermissions); + password = randomPassword(); + loaderPassword = randomPassword(); + evidencePassword = randomPassword(); + createTlsAndAclMaterial(); + container = + new GenericContainer<>(DockerImageName.parse(image)) + .withFileSystemBind( + this.materialDirectory.toString(), "/redis-evidence", BindMode.READ_ONLY) + .withExposedPorts(6379) + .withCommand( + "redis-server", + "--port", + "0", + "--tls-port", + "6379", + "--tls-cert-file", + "/redis-evidence/server.crt", + "--tls-key-file", + "/redis-evidence/server.key", + "--tls-ca-cert-file", + "/redis-evidence/ca.crt", + "--tls-auth-clients", + "no", + "--aclfile", + "/redis-evidence/users.acl") + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + } + + void start() { + container.start(); + } + + String host() { + return container.getHost(); + } + + int port() { + return container.getMappedPort(6379); + } + + char[] password() { + return password.clone(); + } + + byte[] trustPem() { + try { + return Files.readAllBytes(materialDirectory.resolve("ca.crt")); + } catch (IOException exception) { + throw new IllegalStateException("cannot read generated Redis trust evidence", exception); + } + } + + void preloadScripts(byte[]... scripts) { + for (int index = 0; index < scripts.length; index++) { + Path script = materialDirectory.resolve("preload-" + index + ".lua"); + try { + Files.write(script, scripts[index]); + Files.setPosixFilePermissions(script, PosixFilePermissions.fromString("rw-r--r--")); + var result = + container.execInContainer( + "sh", + "-c", + "redis-cli --tls --cacert /redis-evidence/ca.crt --user script-loader" + + " --pass " + + new String(loaderPassword) + + " -x SCRIPT LOAD < /redis-evidence/preload-" + + index + + ".lua"); + if (result.getExitCode() != 0 || !result.getStdout().trim().matches("[0-9a-f]{40}")) { + throw new IllegalStateException("Redis evidence script preload failed"); + } + } catch (IOException | InterruptedException exception) { + if (exception instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new IllegalStateException("Redis evidence script preload failed"); + } + } + } + + String executeAppCommand(String... command) { + if (command.length == 0 + || Arrays.stream(command).anyMatch(value -> value == null || value.isEmpty())) { + throw new IllegalArgumentException("Redis evidence command must be explicit"); + } + List arguments = + new ArrayList<>( + List.of( + "redis-cli", + "--tls", + "--cacert", + "/redis-evidence/ca.crt", + "--user", + "app-user", + "--pass", + new String(password), + "--raw")); + arguments.addAll(List.of(command)); + try { + var result = container.execInContainer(arguments.toArray(String[]::new)); + if (result.getExitCode() != 0) { + throw new IllegalStateException("Redis evidence command failed"); + } + return result.getStdout().strip(); + } catch (IOException exception) { + throw new IllegalStateException("Redis evidence command failed", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Redis evidence command was interrupted", exception); + } + } + + long probeKeyCount() { + try { + var result = + container.execInContainer( + "redis-cli", + "--tls", + "--cacert", + "/redis-evidence/ca.crt", + "--user", + "evidence-user", + "--pass", + new String(evidencePassword), + "--scan", + "--pattern", + RedisSemanticProbePlan.KEY_NAMESPACE_PREFIX + "*"); + if (result.getExitCode() != 0) { + throw new IllegalStateException("Redis evidence key scan failed"); + } + return result.getStdout().lines().filter(line -> !line.isBlank()).count(); + } catch (IOException exception) { + throw new IllegalStateException("Redis evidence key scan failed"); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Redis evidence key scan was interrupted"); + } + } + + @Override + public void close() { + try { + container.close(); + } finally { + Arrays.fill(password, '\0'); + Arrays.fill(loaderPassword, '\0'); + Arrays.fill(evidencePassword, '\0'); + } + } + + private void createTlsAndAclMaterial() { + try { + Files.createDirectories(materialDirectory); + run( + List.of( + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + materialDirectory.resolve("ca.key").toString(), + "-out", + materialDirectory.resolve("ca.crt").toString(), + "-subj", + "/CN=ca-skeleton-redis-evidence-ca", + "-days", + "1")); + run( + List.of( + "openssl", + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + materialDirectory.resolve("server.key").toString(), + "-out", + materialDirectory.resolve("server.csr").toString(), + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1")); + run( + List.of( + "openssl", + "x509", + "-req", + "-in", + materialDirectory.resolve("server.csr").toString(), + "-CA", + materialDirectory.resolve("ca.crt").toString(), + "-CAkey", + materialDirectory.resolve("ca.key").toString(), + "-CAcreateserial", + "-out", + materialDirectory.resolve("server.crt").toString(), + "-days", + "1", + "-copy_extensions", + "copyall")); + try (BufferedWriter writer = + Files.newBufferedWriter( + materialDirectory.resolve("users.acl"), StandardCharsets.US_ASCII)) { + writer.write("user default off\nuser app-user on >"); + writer.write(password); + writer.write(" resetkeys resetchannels"); + for (String keyPattern : keyPatterns) { + writer.write(' '); + writer.write(keyPattern); + } + writer.write(" &* -@all"); + for (String commandPermission : commandPermissions) { + writer.write(' '); + writer.write(commandPermission); + } + writer.write('\n'); + writer.write("user script-loader on >"); + writer.write(loaderPassword); + writer.write(" resetkeys ~* resetchannels &* -@all +ping +script|load\n"); + writer.write("user evidence-user on >"); + writer.write(evidencePassword); + writer.write(" resetkeys ~ca-health:* resetchannels &* -@all +ping +scan\n"); + } + makeContainerReadable(); + } catch (IOException exception) { + throw new IllegalStateException("cannot create Redis TLS/ACL evidence material", exception); + } + } + + private void makeContainerReadable() throws IOException { + try { + Files.setPosixFilePermissions( + materialDirectory, PosixFilePermissions.fromString("rwxr-xr-x")); + for (String name : List.of("ca.crt", "server.crt", "server.key", "users.acl")) { + Files.setPosixFilePermissions( + materialDirectory.resolve(name), PosixFilePermissions.fromString("rw-r--r--")); + } + } catch (UnsupportedOperationException exception) { + throw new IOException("Redis security evidence requires POSIX file permissions", exception); + } + } + + private static void run(List command) { + Process process; + try { + process = new ProcessBuilder(command).redirectErrorStream(true).start(); + process.getInputStream().readNBytes(65_536); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IllegalStateException("Redis TLS evidence material generation failed"); + } + } catch (IOException exception) { + throw new IllegalStateException("OpenSSL is required for Redis security evidence", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Redis security evidence generation was interrupted"); + } + } + + private static char[] randomPassword() { + byte[] entropy = new byte[32]; + RANDOM.nextBytes(entropy); + try { + return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy).toCharArray(); + } finally { + Arrays.fill(entropy, (byte) 0); + } + } + + private static List validateKeyPatterns(List keyPatterns) { + List patterns = List.copyOf(keyPatterns); + if (patterns.isEmpty() + || patterns.stream() + .anyMatch( + pattern -> + pattern == null + || !pattern.startsWith("~") + || pattern.length() < 2 + || pattern.chars().anyMatch(Character::isWhitespace))) { + throw new IllegalArgumentException( + "Redis ACL evidence key patterns must be non-empty ~-prefixed tokens"); + } + return patterns; + } + + private static List validateCommandPermissions(List commandPermissions) { + List permissions = List.copyOf(commandPermissions); + if (permissions.isEmpty() + || permissions.stream() + .anyMatch( + permission -> permission == null || !permission.matches("\\+[a-z0-9@*|_-]+"))) { + throw new IllegalArgumentException( + "Redis ACL evidence command permissions must be explicit lowercase grants"); + } + return permissions; + } + + static List defaultCommandPermissions() { + return List.of( + "+ping", + "+hello", + "+client|setname", + "+get", + "+set", + "+del", + "+exists", + "+type", + "+getrange", + "+pexpire", + "+persist", + "+pttl", + "+time", + "+hmget", + "+hget", + "+hset", + "+hdel", + "+hlen", + "+zscore", + "+zadd", + "+zrem", + "+zcard", + "+zrangebyscore", + "+zpopmin", + "+evalsha", + "+script|load", + "+publish", + "+subscribe", + "+unsubscribe"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisToxiproxyEvidenceContainer.java b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisToxiproxyEvidenceContainer.java new file mode 100644 index 0000000..e385086 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/redisTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisToxiproxyEvidenceContainer.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.utility.DockerImageName; + +final class RedisToxiproxyEvidenceContainer implements AutoCloseable { + + private static final int REDIS_PORT = 6379; + private static final int TOXIPROXY_API_PORT = 8474; + private static final int REDIS_PROXY_PORT = 8666; + + private final Network network = Network.newNetwork(); + private final GenericContainer redis; + private final GenericContainer toxiproxy; + private final HttpClient client = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + + RedisToxiproxyEvidenceContainer(String redisImage, String toxiproxyImage) { + redis = + new GenericContainer<>(DockerImageName.parse(redisImage)) + .withNetwork(network) + .withNetworkAliases("redis-evidence") + .withExposedPorts(REDIS_PORT) + .withStartupTimeout(Duration.ofSeconds(60)); + toxiproxy = + new GenericContainer<>(DockerImageName.parse(toxiproxyImage)) + .withNetwork(network) + .withExposedPorts(TOXIPROXY_API_PORT, REDIS_PROXY_PORT) + .withStartupTimeout(Duration.ofSeconds(60)); + } + + void start() { + redis.start(); + try { + toxiproxy.start(); + send( + "/proxies", + """ + {"name":"redis-evidence","listen":"0.0.0.0:8666","upstream":"redis-evidence:6379","enabled":true} + """); + } catch (RuntimeException exception) { + close(); + throw exception; + } + } + + String host() { + return toxiproxy.getHost(); + } + + int port() { + return toxiproxy.getMappedPort(REDIS_PROXY_PORT); + } + + void disableProxy() { + send("/proxies/redis-evidence", "{\"enabled\":false}"); + } + + void enableProxy() { + send("/proxies/redis-evidence", "{\"enabled\":true}"); + } + + private void send(String path, String json) { + URI uri = + URI.create( + "http://" + + toxiproxy.getHost() + + ":" + + toxiproxy.getMappedPort(TOXIPROXY_API_PORT) + + path); + HttpRequest request = + HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException( + "Toxiproxy API rejected the evidence operation with status " + response.statusCode()); + } + } catch (IOException exception) { + throw new IllegalStateException("Toxiproxy API was unavailable", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Toxiproxy API operation was interrupted", exception); + } + } + + @Override + public void close() { + try { + toxiproxy.close(); + } finally { + try { + redis.close(); + } finally { + network.close(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorkerTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorkerTest.java new file mode 100644 index 0000000..37aeeed --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/BoundedRedisSentinelRefreshWorkerTest.java @@ -0,0 +1,162 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class BoundedRedisSentinelRefreshWorkerTest { + + @Test + void runsOnOneNamedDaemonAndTerminatesWithinShutdownBound() throws Exception { + AtomicReference executionThread = new AtomicReference<>(); + CountDownLatch executed = new CountDownLatch(1); + BoundedRedisSentinelRefreshWorker worker = + new BoundedRedisSentinelRefreshWorker(1, "redis-sentinel-test-worker"); + + assertThat( + worker.execute( + () -> { + executionThread.set(Thread.currentThread()); + executed.countDown(); + })) + .isTrue(); + assertThat(executed.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(executionThread.get().isDaemon()).isTrue(); + assertThat(executionThread.get().getName()).isEqualTo("redis-sentinel-test-worker"); + + worker.shutdown(Duration.ofSeconds(1)); + + executionThread.get().join(1_000); + assertThat(executionThread.get().isAlive()).isFalse(); + assertThat(worker.execute(() -> {})).isFalse(); + } + + @Test + void recurringFailureIsContainedAndDoesNotCancelTheNextFixedDelayRun() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + CountDownLatch secondRun = new CountDownLatch(1); + BoundedRedisSentinelRefreshWorker worker = + new BoundedRedisSentinelRefreshWorker(1, "redis-sentinel-test-worker"); + try { + worker.scheduleWithFixedDelay( + () -> { + if (attempts.incrementAndGet() == 1) { + throw new IllegalStateException("provider endpoint=secret.internal"); + } + secondRun.countDown(); + }, + Duration.ofMillis(5)); + + assertThat(secondRun.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(attempts).hasValueGreaterThanOrEqualTo(2); + } finally { + worker.shutdown(Duration.ofSeconds(1)); + } + } + + @Test + void dueRecurringTaskRunsBeforeAContinuouslyReplenishedImmediateFollowUp() throws Exception { + AtomicLong ticker = new AtomicLong(); + AtomicReference workerReference = new AtomicReference<>(); + java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>(); + CountDownLatch immediateStarted = new CountDownLatch(1); + CountDownLatch releaseImmediate = new CountDownLatch(1); + CountDownLatch recurringRan = new CountDownLatch(1); + CountDownLatch hotFollowUpRan = new CountDownLatch(1); + BoundedRedisSentinelRefreshWorker worker = + new BoundedRedisSentinelRefreshWorker(2, "redis-sentinel-fair-worker", ticker::get); + workerReference.set(worker); + try { + worker.scheduleWithFixedDelay( + () -> { + order.add("recurring"); + recurringRan.countDown(); + }, + Duration.ofNanos(5)); + worker.execute( + () -> { + order.add("immediate"); + immediateStarted.countDown(); + await(releaseImmediate); + workerReference + .get() + .execute( + () -> { + order.add("hot-follow-up"); + hotFollowUpRan.countDown(); + }); + }); + assertThat(immediateStarted.await(2, TimeUnit.SECONDS)).isTrue(); + ticker.set(5); + releaseImmediate.countDown(); + + assertThat(recurringRan.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(hotFollowUpRan.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(order).containsExactly("immediate", "recurring", "hot-follow-up"); + } finally { + releaseImmediate.countDown(); + worker.shutdown(Duration.ofSeconds(1)); + } + } + + @Test + void recurringDeadlineRemainsCorrectAcrossNanoTimeWrap() throws Exception { + AtomicLong ticker = new AtomicLong(Long.MAX_VALUE - 2); + CountDownLatch recurringRan = new CountDownLatch(1); + BoundedRedisSentinelRefreshWorker worker = + new BoundedRedisSentinelRefreshWorker(1, "redis-sentinel-wrap-worker", ticker::get); + try { + worker.scheduleWithFixedDelay(recurringRan::countDown, Duration.ofNanos(5)); + assertThat(recurringRan.getCount()).isEqualTo(1); + + ticker.set(Long.MIN_VALUE + 2); + worker.execute(() -> {}); + + assertThat(recurringRan.await(2, TimeUnit.SECONDS)).isTrue(); + } finally { + worker.shutdown(Duration.ofSeconds(1)); + } + } + + @Test + void shutdownInterruptsACooperativeBlockedTaskAndJoinsItsWorker() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + AtomicReference executionThread = new AtomicReference<>(); + BoundedRedisSentinelRefreshWorker worker = + new BoundedRedisSentinelRefreshWorker(1, "redis-sentinel-blocked-worker"); + worker.execute( + () -> { + executionThread.set(Thread.currentThread()); + started.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException expected) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + }); + assertThat(started.await(2, TimeUnit.SECONDS)).isTrue(); + + worker.shutdown(Duration.ofSeconds(1)); + + assertThat(interrupted.await(1, TimeUnit.SECONDS)).isTrue(); + executionThread.get().join(1_000); + assertThat(executionThread.get().isAlive()).isFalse(); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("test wait interrupted", interrupted); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactoryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactoryTest.java new file mode 100644 index 0000000..4db6c9b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisNativeClientFactoryTest.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisCredentialsProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisURI; +import java.io.IOException; +import java.net.ServerSocket; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class LettuceRedisNativeClientFactoryTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + private static final RedisClientRuntimeSettings SETTINGS = + new RedisClientRuntimeSettings( + "failed-connect-test", + Duration.ofMillis(100), + Duration.ofMillis(250), + Duration.ofMillis(200), + Duration.ofMillis(400), + Duration.ofMillis(500), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void failedTlsConnectClosesClientAndDestroyableCredentialProvider() throws Exception { + LifecycleEvents events = new LifecycleEvents(); + LettuceRedisNativeClientFactory factory = new LettuceRedisNativeClientFactory(events); + AtomicReference trustMaterial = new AtomicReference<>(); + DestroyableRedisCredentialsProvider credentials = + DestroyableRedisCredentialsProvider.from( + "data-runtime", "plain-secret-value".toCharArray()); + try (ServerSocket nonTlsEndpoint = new ServerSocket(0)) { + RedisURI uri = + RedisURI.builder() + .withHost("127.0.0.1") + .withPort(nonTlsEndpoint.getLocalPort()) + .withAuthentication(credentials) + .withSsl(true) + .withVerifyPeer(true) + .build(); + ClientOptions options = + new RedisLettuceClientOptionsFactory() + .clientOptions(SETTINGS, explicitSslOptions(trustMaterial)); + + assertThatThrownBy(() -> factory.openStandalone(uri, options, SETTINGS)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect") + .hasMessageNotContaining("plain-secret-value") + .hasMessageNotContaining("secret://"); + } + + assertThat(credentials.isDestroyed()).isTrue(); + assertThat(trustMaterial.get().isDestroyed()).isTrue(); + assertThat(events.clientsCreated).hasValue(1); + assertThat(events.clientsClosed).hasValue(1); + assertThat(events.connectionsClosed).hasValueLessThanOrEqualTo(1); + } + + private static io.lettuce.core.SslOptions explicitSslOptions( + AtomicReference captured) { + return new RedisSslOptionsFactory( + ignored -> { + VersionedRedisTrustMaterial material = + new VersionedRedisTrustMaterial( + "trust-v1", NOW.plusSeconds(3600), DestroyableRedisPem.from(validPem())); + captured.set(material); + return material; + }, + Clock.fixed(NOW, ZoneOffset.UTC)) + .create( + new RedisDeploymentSettings.Tls(true, true, "secret://redis/test/ca"), + SETTINGS.connectTimeout()); + } + + private static byte[] validPem() { + try { + return LettuceRedisNativeClientFactoryTest.class + .getResourceAsStream("/redis-test-ca.pem") + .readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Redis test CA could not be read", exception); + } + } + + private static final class LifecycleEvents + implements LettuceRedisNativeClientFactory.LifecycleObserver { + + private final AtomicInteger clientsCreated = new AtomicInteger(); + private final AtomicInteger clientsClosed = new AtomicInteger(); + private final AtomicInteger connectionsClosed = new AtomicInteger(); + + @Override + public void clientCreated() { + clientsCreated.incrementAndGet(); + } + + @Override + public void connectionClosed() { + connectionsClosed.incrementAndGet(); + } + + @Override + public void clientClosed() { + clientsClosed.incrementAndGet(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java index e0170e0..308431a 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java @@ -11,18 +11,77 @@ import dev.caskeleton.application.cache.CacheLookup; import dev.caskeleton.application.cache.CacheRecordIntent; import dev.caskeleton.application.cache.CacheRecordMetadata; import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.cache.DisabledCacheObservationPort; +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateParameters; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.codec.ByteArrayCodec; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.Base64; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @Tag("redis-service") class LettuceRedisRuntimeServiceTest { + private static final int MAXIMUM_CACHE_VALUE_BYTES = 16_777_216; + private static final int MAXIMUM_CACHE_COMMAND_BYTES = MAXIMUM_CACHE_VALUE_BYTES + 4096; + + @Test + void twoSessionRepositoriesShareTouchLogoutAndRotationStateWithoutResurrection() { + RedisRuntimeSettings settings = settings(); + byte[] hmacSecret = settings.hmacSecret(); + try (LettuceRedisRuntime podARuntime = LettuceRedisRuntime.connect(settings); + LettuceRedisRuntime podBRuntime = LettuceRedisRuntime.connect(settings); + RedisLuaVersionedSessionStore podAStore = sessionStore(podARuntime, hmacSecret); + RedisLuaVersionedSessionStore podBStore = sessionStore(podBRuntime, hmacSecret)) { + RedisVersionedSessionRepository podA = sessionRepository(podAStore); + RedisVersionedSessionRepository podB = sessionRepository(podBStore); + + RedisVersionedSession created = podA.createSession(); + created.setAttribute("principal", "worklog-user"); + podA.save(created); + + RedisVersionedSession stalePodAView = podA.findById(created.getId()); + RedisVersionedSession podBView = podB.findById(created.getId()); + assertThat(podBView.getAttribute("principal")).isEqualTo("worklog-user"); + + podB.deleteById(created.getId()); + assertThat(podA.findById(created.getId())).isNull(); + stalePodAView.setAttribute("role", "operator"); + assertThatThrownBy(() -> podA.save(stalePodAView)) + .isInstanceOf(RedisSessionConflictException.class) + .hasMessageContaining("TOMBSTONED"); + + RedisVersionedSession rotating = podA.createSession(); + rotating.setAttribute("principal", "rotating-user"); + podA.save(rotating); + String oldId = rotating.getId(); + String newId = rotating.changeSessionId(); + podA.save(rotating); + + assertThat(newId).isNotEqualTo(oldId); + assertThat(podB.findById(oldId)).isNull(); + assertThat(podB.findById(newId).getAttribute("principal")).isEqualTo("rotating-user"); + } finally { + Arrays.fill(hmacSecret, (byte) 0); + } + } + @Test void executesRealTtlExpiryAndCatalogLuaAgainstStandaloneRedis() throws InterruptedException { RedisRuntimeSettings settings = settings(); @@ -34,7 +93,7 @@ class LettuceRedisRuntimeServiceTest { new RedisKeyNamespace( "ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512), settings.hmacSecret(), - Duration.ofMillis(150), + Duration.ofSeconds(1), Duration.ofSeconds(1), 1024), runtime); @@ -45,47 +104,174 @@ class LettuceRedisRuntimeServiceTest { "service-value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) .isEqualTo(CacheRecordOutcome.RECORDED); - assertThat(region.lookup("service-key")) - .isEqualTo( - new CacheLookup.Hit<>("service-value", CacheLookup.Freshness.FRESH, "revision-1")); + CacheLookup lookup = region.lookup("service-key"); + assertThat(lookup).isInstanceOf(CacheLookup.Hit.class); + CacheLookup.Hit hit = (CacheLookup.Hit) lookup; + assertThat(hit.value()).isEqualTo("service-value"); + assertThat(hit.freshness()).isEqualTo(CacheLookup.Freshness.FRESH); + assertThat(hit.sourceRevision()).isEqualTo("revision-1"); + assertThat(hit.softExpiresAt()).isEqualTo(hit.hardExpiresAt()); + + assertThat( + region.record( + "service-key", + "must-not-overwrite", + new CacheRecordMetadata( + "revision-2", + CacheRecordIntent.ONLY_IF_ABSENT, + dev.caskeleton.application.cache.CacheObservationToken.unavailable(), + hit.writeCondition()))) + .isEqualTo(CacheRecordOutcome.NOT_RECORDED_CONDITION); + assertThat( + region.record( + "service-key", + "observed-replacement", + new CacheRecordMetadata( + "revision-2", + CacheRecordIntent.ONLY_IF_OBSERVED, + hit.observationToken(), + hit.writeCondition()))) + .isEqualTo(CacheRecordOutcome.RECORDED); + CacheLookup.Hit secondObservation = + (CacheLookup.Hit) region.lookup("service-key"); + assertThat(secondObservation.value()).isEqualTo("observed-replacement"); + assertThat( + region.record( + "service-key", + "concurrent-writer", + new CacheRecordMetadata("revision-3", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + assertThat( + region.record( + "service-key", + "losing-replacement", + new CacheRecordMetadata( + "revision-2", + CacheRecordIntent.ONLY_IF_OBSERVED, + secondObservation.observationToken(), + secondObservation.writeCondition()))) + .isEqualTo(CacheRecordOutcome.NOT_RECORDED_CONDITION); + assertThat(((CacheLookup.Hit) region.lookup("service-key")).value()) + .isEqualTo("concurrent-writer"); + awaitMiss(region, "service-key"); byte[] leaseKey = "ca:test:lease:{service}".getBytes(UTF_8); - runtime.set(leaseKey, "owner-1".getBytes(UTF_8), Duration.ofSeconds(5)); + runtime.set( + RedisPhysicalKeyTestFactory.fromEncoded(leaseKey), + RedisBinaryValue.utf8("owner-1"), + Duration.ofSeconds(5)); RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); - RedisProgramDescriptor compareDelete = - catalog.descriptors().stream() - .filter(descriptor -> descriptor.argumentCount() == 1) - .findFirst() - .orElseThrow(); + RedisProgramDescriptor compareDelete = catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE); RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, runtime); assertThat( executor.execute( - compareDelete, List.of(leaseKey), List.of("owner-1".getBytes(UTF_8)))) + RedisProgramTestInvocations.scalar( + catalog, + compareDelete.id(), + List.of(leaseKey), + List.of("owner-1".getBytes(UTF_8))))) .isEqualTo("DELETED"); - assertThat(runtime.get(leaseKey)).isNull(); + assertThat(runtime.get(RedisPhysicalKeyTestFactory.fromEncoded(leaseKey))).isNull(); } finally { runtime.close(); } - assertThatThrownBy(() -> runtime.get("closed".getBytes(UTF_8))) + assertThatThrownBy(() -> runtime.get(RedisPhysicalKeyTestFactory.fromUtf8("closed"))) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("closed"); } + @Test + void twoRuntimesPropagateOpaqueInvalidationAndReconnectThroughAGenerationBarrier() + throws InterruptedException { + RedisRuntimeSettings settings = settings(); + RedisKeyNamespace namespace = + new RedisKeyNamespace("ca-skeleton", "test", "cache", "l1-service", 1, 1, "entry", 512); + RedisCacheRegionPolicy cachePolicy = + new RedisCacheRegionPolicy( + namespace, + settings.hmacSecret(), + "l1-service-r1", + Duration.ofSeconds(20), + Duration.ofSeconds(30), + Duration.ofSeconds(5), + 0.0, + Duration.ofSeconds(1), + 1024); + + try (LettuceRedisRuntime publishingRuntime = LettuceRedisRuntime.connect(settings); + LettuceRedisRuntime subscribingRuntime = LettuceRedisRuntime.connect(settings)) { + RedisStringCacheRegion publishingL2 = + new RedisStringCacheRegion(cachePolicy, publishingRuntime); + RedisStringCacheRegion subscribingL2 = + new RedisStringCacheRegion(cachePolicy, subscribingRuntime); + byte[] ownedSecret = settings.hmacSecret(); + RedisCacheInvalidationMessage.Codec codec = + RedisCacheInvalidationMessage.Codec.fromOwnedSecret(ownedSecret); + String channel = subscribingL2.invalidationChannel(); + RedisLocalCacheRegion local = + new RedisLocalCacheRegion( + "l1-service", + subscribingL2, + new RedisLocalCachePolicy( + 16, 65_536, 4096, Duration.ofSeconds(10), Duration.ofSeconds(2), 16), + java.time.Clock.systemUTC(), + DisabledCacheObservationPort.instance(), + channel, + codec, + message -> subscribingRuntime.publishInvalidation(channel, message)); + try { + try (LettuceRedisCacheInvalidationSubscription ignored = + LettuceRedisCacheInvalidationSubscription.subscribe( + subscribingRuntime, channel, codec, local.invalidationSubscriber())) { + publishingL2.record( + "shared-key", "old", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + assertThat(((CacheLookup.Hit) local.lookup("shared-key")).value()) + .isEqualTo("old"); + assertThat(local.localEntryCount()).isEqualTo(1); + + publishingL2.record( + "shared-key", + "new-after-hint", + new CacheRecordMetadata("revision-2", CacheRecordIntent.UPSERT)); + publishingRuntime.publishInvalidation( + channel, + codec.encode( + RedisCacheInvalidationMessage.key( + publishingL2.localEntryIdentity("shared-key")))); + awaitQueuedHint(local.invalidationSubscriber()); + + assertThat(((CacheLookup.Hit) local.lookup("shared-key")).value()) + .isEqualTo("new-after-hint"); + } + + assertThat(local.localEntryCount()).isZero(); + publishingL2.invalidateRegion(); + publishingL2.record( + "shared-key", + "new-generation", + new CacheRecordMetadata("revision-3", CacheRecordIntent.UPSERT)); + + try (LettuceRedisCacheInvalidationSubscription ignored = + LettuceRedisCacheInvalidationSubscription.subscribe( + subscribingRuntime, channel, codec, local.invalidationSubscriber())) { + assertThat(((CacheLookup.Hit) local.lookup("shared-key")).value()) + .isEqualTo("new-generation"); + } + } finally { + local.close(); + } + } + } + @Test void rejectsAnOversizedBulkValueBeforeReturningItToTheSemanticDecoder() { RedisRuntimeSettings settings = settings(); RedisKeyNamespace namespace = new RedisKeyNamespace("ca-skeleton", "test", "cache", "service", 1, 1, "entry", 512); - byte[] semanticKey = "oversized-service-key".getBytes(UTF_8); - byte[] physicalKey = - RedisKeyBuilder.build( - namespace, - RedisKeyDigest.sensitive( - namespace.hashKeyVersion(), settings.hmacSecret(), List.of(semanticKey))) - .getBytes(UTF_8); + byte[] semanticKey = ("oversized-service-key-" + UUID.randomUUID()).getBytes(UTF_8); byte[] oversizedValue = new byte[settings.maximumValueBytes() + 4096]; Arrays.fill(oversizedValue, (byte) 'x'); @@ -94,11 +280,6 @@ class LettuceRedisRuntimeServiceTest { try (StatefulRedisConnection unboundedConnection = unboundedClient.connect(ByteArrayCodec.INSTANCE); LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) { - unboundedConnection.sync().set(physicalKey, oversizedValue); - - assertThatThrownBy(() -> runtime.get(physicalKey)) - .isInstanceOf(RedisValueTooLargeException.class); - RedisStringCacheRegion region = new RedisStringCacheRegion( new RedisCacheRegionPolicy( @@ -108,24 +289,711 @@ class LettuceRedisRuntimeServiceTest { Duration.ofSeconds(1), settings.maximumValueBytes()), runtime); + CacheLookup.Miss initial = + (CacheLookup.Miss) region.lookup(new String(semanticKey, UTF_8)); + String[] condition = initial.writeCondition().value().split("\\.", -1); + byte[] physicalKey = + RedisKeyBuilder.buildVersioned( + namespace, + RedisKeyDigest.sensitive( + namespace.hashKeyVersion(), settings.hmacSecret(), List.of(semanticKey)), + condition[1], + condition[2]) + .getBytes(UTF_8); + unboundedConnection.sync().set(physicalKey, oversizedValue); + + assertThatThrownBy(() -> runtime.get(RedisPhysicalKeyTestFactory.fromEncoded(physicalKey))) + .isInstanceOf(RedisValueTooLargeException.class); assertThat(region.lookup(new String(semanticKey, UTF_8))) .isEqualTo( new CacheLookup.IncompatibleSchema<>( - CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, - CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.FAIL_FAST, + dev.caskeleton.application.cache.CacheObservationToken.unavailable(), + initial.writeCondition())); } finally { unboundedClient.shutdown(Duration.ZERO, settings.commandTimeout()); } } + @Test + void interruptedMutationRestoresTheFlagAndReportsIndeterminateCertainty() { + RedisRuntimeSettings settings = settings(); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) { + Thread.currentThread().interrupt(); + + assertThatThrownBy( + () -> + runtime.set( + RedisPhysicalKeyTestFactory.fromUtf8("ca:test:interrupt:{service}"), + RedisBinaryValue.utf8("value"), + Duration.ofSeconds(5))) + .isInstanceOfSatisfying( + RedisCommandFailureException.class, + failure -> + assertThat(failure.certainty()) + .isEqualTo(RedisCommandFailureException.Certainty.INDETERMINATE)); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(Thread.interrupted()).isTrue(); + } finally { + Thread.interrupted(); + } + } + + @Test + void admitsTheExactSixteenMebibytePayloadAndRejectsOneAdditionalByte() { + RedisRuntimeSettings settings = maximumPayloadSettings(); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings)) { + RedisStringCacheRegion region = + new RedisStringCacheRegion( + new RedisCacheRegionPolicy( + new RedisKeyNamespace( + "ca-skeleton", "test", "cache", "maximum", 1, 1, "entry", 512), + settings.hmacSecret(), + Duration.ofMinutes(5), + Duration.ofSeconds(1), + MAXIMUM_CACHE_VALUE_BYTES), + runtime); + String exactPayload = "x".repeat(MAXIMUM_CACHE_VALUE_BYTES); + + assertThat( + region.record( + "maximum-payload", + exactPayload, + new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + CacheLookup.Hit observed = (CacheLookup.Hit) region.lookup("maximum-payload"); + assertThat(observed.value()).hasSize(MAXIMUM_CACHE_VALUE_BYTES); + assertThat( + region.record( + "maximum-payload", + "y".repeat(MAXIMUM_CACHE_VALUE_BYTES), + new CacheRecordMetadata( + "revision-2", + CacheRecordIntent.ONLY_IF_OBSERVED, + observed.observationToken(), + observed.writeCondition()))) + .isEqualTo(CacheRecordOutcome.RECORDED); + + assertThatThrownBy( + () -> + region.record( + "too-large-payload", + "z".repeat(MAXIMUM_CACHE_VALUE_BYTES + 1), + new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exceeds configured maximum"); + } + } + + @Test + void executesBoundaryAndDenialWithoutConsumptionForAllThreeRatePrograms() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = + new RedisLegacyStandaloneSettings( + cacheSettings.host(), + cacheSettings.port(), + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + Map policies = + Map.of( + "service-fixed", + policy( + "service-fixed", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(3, Duration.ofDays(1)), + 2), + "service-sliding", + policy( + "service-sliding", + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(3, Duration.ofDays(1)), + 2), + "service-token", + policy( + "service-token", + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(3, 1, Duration.ofDays(1)), + 2)); + + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisEdgeRateLimitProvider provider = + new RedisEdgeRateLimitProvider( + policies, + catalog, + new RedisStructuredProgramExecutor(catalog, runtime), + "ca-skeleton", + "test", + 1, + 1, + rateConnection.hmacSecret(), + java.time.Clock.systemUTC(), + Duration.ofMillis(100), + rateConnection.commandTimeout()); + + for (String policyId : policies.keySet()) { + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + Instant deadline = Instant.now().plusSeconds(5); + RateLimitRequest costTwo = new RateLimitRequest(policyId, subject, 2, "", deadline); + RateLimitRequest costOne = new RateLimitRequest(policyId, subject, 1, "", deadline); + + assertThat(decision(provider.evaluate(costTwo)).allowed()).isTrue(); + assertThat(decision(provider.evaluate(costTwo))) + .satisfies( + denied -> { + assertThat(denied.allowed()).isFalse(); + assertThat(denied.remaining()).isEqualTo(1); + }); + assertThat(decision(provider.evaluate(costOne))) + .satisfies( + exactBoundary -> { + assertThat(exactBoundary.allowed()).isTrue(); + assertThat(exactBoundary.remaining()).isZero(); + }); + } + } + } + + @Test + void responseLossReplayDoesNotConsumeTwiceForAnyRateAlgorithm() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = rateConnection(cacheSettings); + Map policies = + Map.of( + "replay-fixed", + policy( + "replay-fixed", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(3, Duration.ofDays(1)), + 2), + "replay-sliding", + policy( + "replay-sliding", + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(3, Duration.ofDays(1)), + 2), + "replay-token", + policy( + "replay-token", + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(3, 1, Duration.ofDays(1)), + 2)); + + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisEdgeRateLimitProvider provider = provider(policies, catalog, runtime, rateConnection); + + for (String policyId : policies.keySet()) { + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + Instant deadline = Instant.now().plusSeconds(5); + RateLimitRequest first = + new RateLimitRequest(policyId, subject, 2, "ev1:AAAAAAAAAAAAAAAAAAAAAA", deadline); + RateLimitRequest second = + new RateLimitRequest(policyId, subject, 2, "ev1:BBBBBBBBBBBBBBBBBBBBBB", deadline); + + dev.caskeleton.shared.ratelimit.RateLimitDecision firstDecision = + decision(provider.evaluate(first)); + // Simulate a caller that lost the first response and retries the same evaluation. + assertThat(decision(provider.evaluate(first))).isEqualTo(firstDecision); + assertThat(firstDecision.allowed()).isTrue(); + assertThat(firstDecision.remaining()).isEqualTo(1); + + dev.caskeleton.shared.ratelimit.RateLimitDecision secondDecision = + decision(provider.evaluate(second)); + assertThat(secondDecision.allowed()).isFalse(); + assertThat(secondDecision.remaining()).isEqualTo(1); + assertThat(decision(provider.evaluate(second))).isEqualTo(secondDecision); + } + } + } + + @Test + void providerRecoversOneLostRedisResponseWithTheSameEvaluationId() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = rateConnection(cacheSettings); + String policyId = "provider-response-loss"; + RateLimitPolicy ratePolicy = + policy( + policyId, + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(3, Duration.ofDays(1))); + + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisRateProgramExecutor delegate = new RedisStructuredProgramExecutor(catalog, runtime); + AtomicInteger sends = new AtomicInteger(); + RedisRateProgramExecutor responseLoss = + invocation -> { + RedisRateProgramReply applied = delegate.execute(invocation); + if (sends.getAndIncrement() == 0) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "simulated response loss after Redis applied the program", + null); + } + return applied; + }; + RedisEdgeRateLimitProvider provider = + new RedisEdgeRateLimitProvider( + Map.of(policyId, ratePolicy), + catalog, + responseLoss, + "ca-skeleton", + "test", + 1, + 1, + rateConnection.hmacSecret(), + java.time.Clock.systemUTC(), + Duration.ofMillis(100), + rateConnection.commandTimeout()); + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + Instant deadline = Instant.now().plusSeconds(10); + + assertThat( + decision( + provider.evaluate( + new RateLimitRequest( + policyId, subject, 1, "ev1:AAAAAAAAAAAAAAAAAAAAAA", deadline))) + .remaining()) + .isEqualTo(2); + assertThat(sends).hasValue(2); + assertThat( + decision( + provider.evaluate( + new RateLimitRequest( + policyId, subject, 1, "ev1:BBBBBBBBBBBBBBBBBBBBBB", deadline))) + .remaining()) + .isEqualTo(1); + } + } + + @Test + void dedupStateIsEntryAndTtlBoundedAndMalformedInputWritesNothing() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = rateConnection(cacheSettings); + String policyId = "bounded-dedup"; + RateLimitPolicy boundedPolicy = + new RateLimitPolicy( + policyId, + "service-v1", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofDays(1)), + 1, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED, + new RateLimitEvaluationDedupPolicy(true, Duration.ofSeconds(5), 2, 398)); + io.lettuce.core.RedisClient rawClient = + io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(cacheSettings)); + + try (StatefulRedisConnection rawConnection = + rawClient.connect(ByteArrayCodec.INSTANCE); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisEdgeRateLimitProvider provider = + provider(Map.of(policyId, boundedPolicy), catalog, runtime, rateConnection); + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + Instant deadline = Instant.now().plusSeconds(5); + for (String token : + List.of( + "ev1:AAAAAAAAAAAAAAAAAAAAAA", + "ev1:BBBBBBBBBBBBBBBBBBBBBB", + "ev1:CCCCCCCCCCCCCCCCCCCCCC")) { + assertThat( + decision( + provider.evaluate( + new RateLimitRequest(policyId, subject, 1, token, deadline))) + .allowed()) + .isTrue(); + } + byte[] dedupKey = + physicalRateKey(boundedPolicy, subject, rateConnection.hmacSecret(), "dedup"); + byte[] orderKey = + physicalRateKey(boundedPolicy, subject, rateConnection.hmacSecret(), "dedup-order"); + assertThat(rawConnection.sync().hlen(dedupKey)).isEqualTo(2); + assertThat(rawConnection.sync().zcard(orderKey)).isEqualTo(2); + assertThat(rawConnection.sync().pttl(dedupKey)).isBetween(1L, 5000L); + assertThat(rawConnection.sync().pttl(orderKey)).isBetween(1L, 5000L); + + for (String invalidEvaluationId : List.of("caller-controlled", "ev1:" + "X".repeat(68))) { + String slot = "invalid-" + UUID.randomUUID().toString().replace("-", ""); + List invalidKeys = + List.of( + ("ca:test:rate:{" + slot + "}:state").getBytes(UTF_8), + ("ca:test:rate:{" + slot + "}:dedup").getBytes(UTF_8), + ("ca:test:rate:{" + slot + "}:dedup-order").getBytes(UTF_8)); + RedisRateProgramReply invalid = + new RedisStructuredProgramExecutor(catalog, runtime) + .execute( + RedisProgramTestInvocations.structured( + catalog, + RedisProgramId.RATE_FIXED_WINDOW_V2, + invalidKeys, + List.of( + "2".getBytes(UTF_8), + "service-v1".getBytes(UTF_8), + "10".getBytes(UTF_8), + "1".getBytes(UTF_8), + "10000".getBytes(UTF_8), + "5000".getBytes(UTF_8), + "250".getBytes(UTF_8), + invalidEvaluationId.getBytes(UTF_8), + "5000".getBytes(UTF_8), + "2".getBytes(UTF_8), + "398".getBytes(UTF_8)))); + + assertThat(invalid.status()).isEqualTo(RedisRateProgramStatus.INVALID); + assertThat(rawConnection.sync().exists(invalidKeys.toArray(byte[][]::new))).isZero(); + } + } finally { + rawClient.shutdown(Duration.ZERO, cacheSettings.commandTimeout()); + } + } + + @Test + void newerTrafficCannotExtendAnOlderEvaluationReplayLifetime() throws InterruptedException { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = rateConnection(cacheSettings); + String policyId = "individual-dedup-ttl"; + RateLimitPolicy ratePolicy = + new RateLimitPolicy( + policyId, + "service-v1", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(3, Duration.ofDays(1)), + 1, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED, + new RateLimitEvaluationDedupPolicy(true, Duration.ofSeconds(1), 8, 1592)); + io.lettuce.core.RedisClient rawClient = + io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(cacheSettings)); + + try (StatefulRedisConnection rawConnection = + rawClient.connect(ByteArrayCodec.INSTANCE); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisEdgeRateLimitProvider provider = + provider(Map.of(policyId, ratePolicy), catalog, runtime, rateConnection); + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + Instant deadline = Instant.now().plusSeconds(10); + String firstId = "ev1:AAAAAAAAAAAAAAAAAAAAAA"; + String newerId = "ev1:BBBBBBBBBBBBBBBBBBBBBB"; + + assertThat( + decision( + provider.evaluate( + new RateLimitRequest(policyId, subject, 1, firstId, deadline))) + .remaining()) + .isEqualTo(2); + Thread.sleep(700); + assertThat( + decision( + provider.evaluate( + new RateLimitRequest(policyId, subject, 1, newerId, deadline))) + .remaining()) + .isEqualTo(1); + Thread.sleep(500); + + byte[] dedupKey = physicalRateKey(ratePolicy, subject, rateConnection.hmacSecret(), "dedup"); + assertThat(rawConnection.sync().hget(dedupKey, firstId.getBytes(UTF_8))).isNotNull(); + assertThat(rawConnection.sync().pttl(dedupKey)).isPositive(); + assertThat( + decision( + provider.evaluate( + new RateLimitRequest(policyId, subject, 1, firstId, deadline))) + .remaining()) + .isZero(); + } finally { + rawClient.shutdown(Duration.ZERO, cacheSettings.commandTimeout()); + } + } + + @Test + void tokenBucketCarriesSeededFractionalRefillRemainderInRealLua() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = + new RedisLegacyStandaloneSettings( + cacheSettings.host(), + cacheSettings.port(), + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + io.lettuce.core.RedisClient rawClient = + io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(cacheSettings)); + try (StatefulRedisConnection rawConnection = + rawClient.connect(ByteArrayCodec.INSTANCE); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + long periodMillis = 1009; + long elapsedMillis = 100; + long refillScaled = 1_000_000; + long partialProduct = elapsedMillis * refillScaled; + long partialTokens = partialProduct / periodMillis; + long partialRemainder = partialProduct % periodMillis; + long storedRemainder = periodMillis - partialRemainder; + long storedTokens = 1_000_000 - partialTokens - 1; + List redisTime = rawConnection.sync().time(); + long redisNowMillis = + Long.parseLong(new String(redisTime.get(0), UTF_8)) * 1000 + + Long.parseLong(new String(redisTime.get(1), UTF_8)) / 1000; + long clampedFutureMillis = redisNowMillis + 5000; + byte[] key = + ("ca:test:rate:{remainder}:" + UUID.randomUUID().toString().replace("-", "")) + .getBytes(UTF_8); + IdentityHashMap seededState = new IdentityHashMap<>(); + seededState.put("schema".getBytes(UTF_8), "1".getBytes(UTF_8)); + seededState.put("algorithm".getBytes(UTF_8), "token-bucket".getBytes(UTF_8)); + seededState.put("policyRevision".getBytes(UTF_8), "service-v1".getBytes(UTF_8)); + seededState.put( + "lastObservedMillis".getBytes(UTF_8), Long.toString(clampedFutureMillis).getBytes(UTF_8)); + seededState.put("tokensScaled".getBytes(UTF_8), Long.toString(storedTokens).getBytes(UTF_8)); + seededState.put( + "lastRefillMillis".getBytes(UTF_8), + Long.toString(clampedFutureMillis - elapsedMillis).getBytes(UTF_8)); + seededState.put( + "refillRemainder".getBytes(UTF_8), Long.toString(storedRemainder).getBytes(UTF_8)); + rawConnection.sync().hset(key, seededState); + + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisRateProgramReply reply = + new RedisStructuredProgramExecutor(catalog, runtime) + .execute( + RedisProgramTestInvocations.structured( + catalog, + RedisProgramId.RATE_TOKEN_BUCKET, + List.of(key), + List.of( + "1".getBytes(UTF_8), + "service-v1".getBytes(UTF_8), + "1000000".getBytes(UTF_8), + "1000000".getBytes(UTF_8), + Long.toString(periodMillis).getBytes(UTF_8), + "1000000".getBytes(UTF_8), + "5000".getBytes(UTF_8), + "10000".getBytes(UTF_8)))); + + assertThat(partialRemainder).isNotZero(); + assertThat(reply.status()).isEqualTo(RedisRateProgramStatus.ALLOWED); + assertThat(reply.effectiveNowMillis()).isEqualTo(clampedFutureMillis); + assertThat(reply.remaining()).isZero(); + } finally { + rawClient.shutdown(Duration.ZERO, cacheSettings.commandTimeout()); + } + } + + @Test + void malformedRateHashReturnsTypedStateIncompatibilityInsteadOfALuaError() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = + new RedisLegacyStandaloneSettings( + cacheSettings.host(), + cacheSettings.port(), + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + String policyId = "service-malformed-fixed"; + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + RateLimitPolicy ratePolicy = + policy( + policyId, + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofSeconds(10))); + byte[] physicalKey = + RedisKeyBuilder.build( + new RedisKeyNamespace("ca-skeleton", "test", "rate", policyId, 1, 1, "state", 512), + RedisKeyDigest.sensitive( + 1, + rateConnection.hmacSecret(), + List.of( + policyId.getBytes(UTF_8), + "service-v1".getBytes(UTF_8), + "fixed-window".getBytes(UTF_8), + subject.getBytes(UTF_8)))) + .getBytes(UTF_8); + IdentityHashMap incompleteState = new IdentityHashMap<>(); + incompleteState.put("schema".getBytes(UTF_8), "1".getBytes(UTF_8)); + + io.lettuce.core.RedisClient rawClient = + io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(cacheSettings)); + try (StatefulRedisConnection rawConnection = + rawClient.connect(ByteArrayCodec.INSTANCE); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + rawConnection.sync().hset(physicalKey, incompleteState); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisEdgeRateLimitProvider provider = + new RedisEdgeRateLimitProvider( + Map.of(policyId, ratePolicy), + catalog, + new RedisStructuredProgramExecutor(catalog, runtime), + "ca-skeleton", + "test", + 1, + 1, + rateConnection.hmacSecret(), + java.time.Clock.systemUTC(), + Duration.ofMillis(100), + rateConnection.commandTimeout()); + + assertThat( + provider.evaluate( + new RateLimitRequest(policyId, subject, 1, "", Instant.now().plusSeconds(5)))) + .isEqualTo( + new RateLimitOutcome.Incompatible( + policyId, RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE)); + } finally { + rawClient.shutdown(Duration.ZERO, cacheSettings.commandTimeout()); + } + } + + @Test + void v1StateIsNeverMisreadAsV2DuringRollingDeployment() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = rateConnection(cacheSettings); + String policyId = "service-v1-to-v2-fixed"; + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + RateLimitPolicy ratePolicy = + policy( + policyId, + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofSeconds(10))); + byte[] physicalKey = physicalRateKey(ratePolicy, subject, rateConnection.hmacSecret()); + + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisRateProgramReply v1 = + new RedisStructuredProgramExecutor(catalog, runtime) + .execute( + RedisProgramTestInvocations.structured( + catalog, + RedisProgramId.RATE_FIXED_WINDOW, + List.of(physicalKey), + List.of( + "1".getBytes(UTF_8), + "service-v1".getBytes(UTF_8), + "10".getBytes(UTF_8), + "1".getBytes(UTF_8), + "10000".getBytes(UTF_8), + "5000".getBytes(UTF_8), + "250".getBytes(UTF_8)))); + assertThat(v1.status()).isEqualTo(RedisRateProgramStatus.ALLOWED); + + RedisEdgeRateLimitProvider v2Provider = + provider(Map.of(policyId, ratePolicy), catalog, runtime, rateConnection); + assertThat( + v2Provider.evaluate( + new RateLimitRequest(policyId, subject, 1, "", Instant.now().plusSeconds(5)))) + .isEqualTo( + new RateLimitOutcome.Incompatible( + policyId, RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE)); + } + } + + @Test + void excessiveRedisClockRegressionLeavesEveryAlgorithmStateUnchanged() { + RedisRuntimeSettings cacheSettings = settings(); + RedisLegacyStandaloneSettings rateConnection = + new RedisLegacyStandaloneSettings( + cacheSettings.host(), + cacheSettings.port(), + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + List policies = + List.of( + policy( + "clock-fixed", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(3, Duration.ofSeconds(10))), + policy( + "clock-sliding", + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(3, Duration.ofSeconds(10))), + policy( + "clock-token", + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(3, 1, Duration.ofSeconds(10)))); + io.lettuce.core.RedisClient rawClient = + io.lettuce.core.RedisClient.create(LettuceRedisRuntime.redisUri(cacheSettings)); + + try (StatefulRedisConnection rawConnection = + rawClient.connect(ByteArrayCodec.INSTANCE); + LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(rateConnection)) { + List redisTime = rawConnection.sync().time(); + long redisNowMillis = + Long.parseLong(new String(redisTime.get(0), UTF_8)) * 1000 + + Long.parseLong(new String(redisTime.get(1), UTF_8)) / 1000; + long futureMillis = redisNowMillis + 5000; + + for (RateLimitPolicy ratePolicy : policies) { + String subject = "service:" + UUID.randomUUID().toString().replace("-", ""); + byte[] physicalKey = physicalRateKey(ratePolicy, subject, rateConnection.hmacSecret()); + IdentityHashMap seededState = + clockRegressionState(ratePolicy, futureMillis); + rawConnection.sync().hset(physicalKey, seededState); + Map before = decodedState(rawConnection.sync().hgetall(physicalKey)); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisEdgeRateLimitProvider provider = + new RedisEdgeRateLimitProvider( + Map.of(ratePolicy.policyId(), ratePolicy), + catalog, + new RedisStructuredProgramExecutor(catalog, runtime), + "ca-skeleton", + "test", + 1, + 1, + rateConnection.hmacSecret(), + java.time.Clock.systemUTC(), + Duration.ofMillis(100), + rateConnection.commandTimeout()); + + assertThat( + provider.evaluate( + new RateLimitRequest( + ratePolicy.policyId(), subject, 1, "", Instant.now().plusSeconds(5)))) + .isEqualTo( + new RateLimitOutcome.Unavailable( + ratePolicy.policyId(), + Duration.ofMillis(100), + RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE)); + assertThat(decodedState(rawConnection.sync().hgetall(physicalKey))).isEqualTo(before); + } + } finally { + rawClient.shutdown(Duration.ZERO, cacheSettings.commandTimeout()); + } + } + private static void awaitMiss(RedisStringCacheRegion region, String key) throws InterruptedException { long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); CacheLookup result; do { result = region.lookup(key); - if (result instanceof CacheLookup.Miss) { - assertThat(result).isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + if (result instanceof CacheLookup.Miss miss) { + assertThat(miss.reason()).isEqualTo(CacheLookup.MissReason.ABSENT); + assertThat(miss.writeCondition().usable()).isTrue(); return; } Thread.sleep(20); @@ -134,6 +1002,15 @@ class LettuceRedisRuntimeServiceTest { "Redis key did not expire within the qualification deadline: " + result); } + private static void awaitQueuedHint(RedisCacheInvalidationSubscriber subscriber) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); + while (subscriber.queuedHintCount() == 0 && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertThat(subscriber.queuedHintCount()).isGreaterThan(0); + } + private static RedisRuntimeSettings settings() { String host = requiredProperty("redis.test.host"); int port = Integer.parseInt(requiredProperty("redis.test.port")); @@ -153,6 +1030,172 @@ class LettuceRedisRuntimeServiceTest { 1024); } + private static RedisLuaVersionedSessionStore sessionStore( + RedisStructuredCommands commands, byte[] hmacSecret) { + return new RedisLuaVersionedSessionStore(commands, "ca-skeleton", "test", 1, 1, hmacSecret); + } + + private static RedisVersionedSessionRepository sessionRepository( + VersionedRedisSessionStore store) { + return new RedisVersionedSessionRepository( + store, + new RedisSessionEnvelopeCodec(32_768, 64, 8_192), + Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(30), + Duration.ofMillis(100), + Duration.ofSeconds(10)); + } + + private static RedisRuntimeSettings maximumPayloadSettings() { + String host = requiredProperty("redis.test.host"); + int port = Integer.parseInt(requiredProperty("redis.test.port")); + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + host, + port, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(10), + Duration.ofMinutes(5), + Duration.ofMinutes(4), + Duration.ofSeconds(30), + 0.0, + Duration.ofSeconds(1), + "ca-skeleton", + "test", + "maximum", + MAXIMUM_CACHE_VALUE_BYTES, + 1, + MAXIMUM_CACHE_COMMAND_BYTES); + } + + private static RedisLegacyStandaloneSettings rateConnection(RedisRuntimeSettings cacheSettings) { + return new RedisLegacyStandaloneSettings( + cacheSettings.host(), + cacheSettings.port(), + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + } + + private static RedisEdgeRateLimitProvider provider( + Map policies, + RedisProgramCatalog catalog, + LettuceRedisRuntime runtime, + RedisLegacyStandaloneSettings rateConnection) { + return new RedisEdgeRateLimitProvider( + policies, + catalog, + new RedisStructuredProgramExecutor(catalog, runtime), + "ca-skeleton", + "test", + 1, + 1, + rateConnection.hmacSecret(), + java.time.Clock.systemUTC(), + Duration.ofMillis(100), + rateConnection.commandTimeout()); + } + + private static RateLimitPolicy policy( + String id, RateLimitAlgorithm algorithm, RateParameters parameters) { + return policy(id, algorithm, parameters, 1); + } + + private static RateLimitPolicy policy( + String id, RateLimitAlgorithm algorithm, RateParameters parameters, long maximumCost) { + return new RateLimitPolicy( + id, + "service-v1", + algorithm, + parameters, + maximumCost, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED); + } + + private static byte[] physicalRateKey(RateLimitPolicy policy, String subject, byte[] hmacSecret) { + return physicalRateKey(policy, subject, hmacSecret, "state"); + } + + private static byte[] physicalRateKey( + RateLimitPolicy policy, String subject, byte[] hmacSecret, String kind) { + String algorithm = + switch (policy.algorithm()) { + case FIXED_WINDOW -> "fixed-window"; + case SLIDING_COUNTER -> "sliding-window-counter"; + case TOKEN_BUCKET -> "token-bucket"; + }; + RedisKeyNamespace namespace = + new RedisKeyNamespace("ca-skeleton", "test", "rate", policy.policyId(), 1, 1, kind, 512); + return RedisKeyBuilder.build( + namespace, + RedisKeyDigest.sensitive( + 1, + hmacSecret, + List.of( + policy.policyId().getBytes(UTF_8), + policy.policyRevision().getBytes(UTF_8), + algorithm.getBytes(UTF_8), + subject.getBytes(UTF_8)))) + .getBytes(UTF_8); + } + + private static IdentityHashMap clockRegressionState( + RateLimitPolicy policy, long futureMillis) { + IdentityHashMap state = new IdentityHashMap<>(); + state.put("schema".getBytes(UTF_8), "2".getBytes(UTF_8)); + state.put("policyRevision".getBytes(UTF_8), policy.policyRevision().getBytes(UTF_8)); + state.put("lastObservedMillis".getBytes(UTF_8), Long.toString(futureMillis).getBytes(UTF_8)); + switch (policy.parameters()) { + case RateParameters.FixedWindow fixed -> { + state.put("algorithm".getBytes(UTF_8), "fixed-window".getBytes(UTF_8)); + state.put( + "windowId".getBytes(UTF_8), + Long.toString(futureMillis / fixed.window().toMillis()).getBytes(UTF_8)); + state.put("consumed".getBytes(UTF_8), "1".getBytes(UTF_8)); + } + case RateParameters.SlidingCounter sliding -> { + long currentWindowId = futureMillis / sliding.window().toMillis(); + state.put("algorithm".getBytes(UTF_8), "sliding-window-counter".getBytes(UTF_8)); + state.put( + "previousWindowId".getBytes(UTF_8), Long.toString(currentWindowId - 1).getBytes(UTF_8)); + state.put("previousCount".getBytes(UTF_8), "0".getBytes(UTF_8)); + state.put( + "currentWindowId".getBytes(UTF_8), Long.toString(currentWindowId).getBytes(UTF_8)); + state.put("currentCount".getBytes(UTF_8), "1".getBytes(UTF_8)); + } + case RateParameters.TokenBucket ignored -> { + state.put("algorithm".getBytes(UTF_8), "token-bucket".getBytes(UTF_8)); + state.put("tokensScaled".getBytes(UTF_8), "2000000".getBytes(UTF_8)); + state.put("lastRefillMillis".getBytes(UTF_8), Long.toString(futureMillis).getBytes(UTF_8)); + state.put("refillRemainder".getBytes(UTF_8), "0".getBytes(UTF_8)); + } + } + return state; + } + + private static Map decodedState(Map state) { + Map decoded = new TreeMap<>(); + state.forEach( + (field, value) -> decoded.put(new String(field, UTF_8), new String(value, UTF_8))); + return decoded; + } + + private static dev.caskeleton.shared.ratelimit.RateLimitDecision decision( + RateLimitOutcome outcome) { + assertThat(outcome).isInstanceOf(RateLimitOutcome.Evaluated.class); + return ((RateLimitOutcome.Evaluated) outcome).decision(); + } + private static String requiredProperty(String name) { String value = System.getProperty(name); if (value == null || value.isBlank()) { diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java index 0b3bf3e..d467392 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeTest.java @@ -54,7 +54,7 @@ class LettuceRedisRuntimeTest { "worklog", 1024, 17, - 65_536); + 131_072); ClientOptions options = LettuceRedisRuntime.clientOptions(settings); diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPortTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPortTest.java new file mode 100644 index 0000000..705f91e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerCacheObservationPortTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.cache.CacheObservationEvent; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class MicrometerCacheObservationPortTest { + + @Test + void recordsOnlyRegistryApprovedLowCardinalityCacheMetrics() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MicrometerCacheObservationPort observations = + new MicrometerCacheObservationPort(registry, Set.of("worklog")); + + observations.observe( + new CacheObservationEvent.Lookup( + "worklog", + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.HIT, + Duration.ofMillis(250))); + observations.observe( + new CacheObservationEvent.LocalMaintenance( + "worklog", + CacheObservationEvent.MaintenanceAction.RECONCILE, + CacheObservationEvent.MaintenanceResult.FLUSHED, + CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED, + 2)); + + assertThat( + registry + .get("cache.local.requests.total") + .tags("cache_name", "worklog", "result", "hit") + .counter() + .count()) + .isEqualTo(1); + assertThat( + registry + .get("cache.local.entry.age.seconds") + .tag("cache_name", "worklog") + .timer() + .count()) + .isEqualTo(1); + assertThat( + registry + .get("cache.local.maintenance.total") + .tags("cache_name", "worklog", "event", "reconcile_generation_changed") + .counter() + .count()) + .isEqualTo(1); + assertThat(registry.getMeters()) + .allMatch( + meter -> + meter.getId().getTags().stream() + .noneMatch( + tag -> + tag.getKey().contains("key") + || tag.getValue().contains("customer-email"))); + } + + @Test + void rejectsUnknownCacheNamesAndAllowlistCardinalityAboveFifty() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MicrometerCacheObservationPort observations = + new MicrometerCacheObservationPort(registry, Set.of("worklog")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + observations.observe( + new CacheObservationEvent.Lookup( + "unknown", + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.MISS, + Duration.ZERO))) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + new MicrometerCacheObservationPort( + registry, + java.util.stream.IntStream.range(0, 51) + .mapToObj(index -> "cache-" + index) + .collect(java.util.stream.Collectors.toSet()))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPortTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPortTest.java new file mode 100644 index 0000000..fd524eb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/MicrometerRedisCapabilityObservationPortTest.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.concurrent.CyclicBarrier; +import org.junit.jupiter.api.Test; + +class MicrometerRedisCapabilityObservationPortTest { + + @Test + void rendersExactlyTheSixApprovedMetersFromClosedTags() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RedisCapabilityObservationPort observations = + new SafeRedisCapabilityObservationPort( + new MicrometerRedisCapabilityObservationPort(registry)); + + observations.observe( + new RedisCapabilityObservationEvent.OperationCompleted( + RedisCapabilityObservationEvent.Capability.RATE_LIMIT, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.Operation.RATE_EVALUATE, + RedisCapabilityObservationEvent.Outcome.DENIED, + RedisCapabilityObservationEvent.Certainty.DEFINITE, + 2_000_000)); + observations.observe( + new RedisCapabilityObservationEvent.AdmissionChanged( + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED, + RedisCapabilityObservationEvent.InFlightState.SATURATED, + 4, + 4096)); + observations.observe( + new RedisCapabilityObservationEvent.ReadinessObserved( + RedisCapabilityObservationEvent.Capability.RATE_LIMIT, + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisHealthSnapshotProvider.State.UNAVAILABLE, + RedisHealthSnapshotProvider.Reason.COMMAND_UNAVAILABLE, + RedisCapabilityObservationEvent.Requirement.REQUIRED)); + observations.observe( + new RedisCapabilityObservationEvent.LifecycleDrainCompleted( + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.DrainOutcome.FORCED_AFTER_TIMEOUT)); + + assertThat( + registry + .get("redis.capability.inflight.total") + .tags("role", "coordination", "state", "saturated") + .gauge() + .value()) + .isEqualTo(4); + observations.observe( + new RedisCapabilityObservationEvent.AdmissionChanged( + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.AdmissionState.ADMITTED, + RedisCapabilityObservationEvent.InFlightState.IDLE, + 0, + 0)); + + assertThat(registry.getMeters().stream().map(meter -> meter.getId().getName()).distinct()) + .containsExactlyInAnyOrder( + "redis.capability.operations.total", + "redis.capability.duration.seconds", + "redis.capability.admission.rejected.total", + "redis.capability.inflight.total", + "redis.capability.readiness.total", + "redis.capability.lifecycle.drain.total"); + assertThat( + registry + .get("redis.capability.inflight.total") + .tags("role", "coordination", "state", "saturated") + .gauge() + .value()) + .isZero(); + assertThat( + registry + .get("redis.capability.inflight.total") + .tags("role", "coordination", "state", "idle") + .gauge() + .value()) + .isZero(); + assertThat( + registry + .get("redis.capability.operations.total") + .tags( + "capability", "rate_limit", + "role", "coordination", + "operation", "rate_evaluate", + "redis_outcome", "denied", + "certainty", "definite") + .counter() + .count()) + .isEqualTo(1); + assertThat( + registry + .get("redis.capability.duration.seconds") + .tags( + "capability", "rate_limit", + "role", "coordination", + "operation", "rate_evaluate", + "redis_outcome", "denied") + .timer() + .totalTime(java.util.concurrent.TimeUnit.MILLISECONDS)) + .isEqualTo(2); + assertThat(registry.getMeters()) + .allMatch( + meter -> + meter.getId().getTags().stream() + .noneMatch( + tag -> + tag.getKey() + .matches( + ".*(key|subject|session|token|secret|endpoint|exception|script|sha|cursor|coordinate|value).*"))); + } + + @Test + void concurrentTransitionsLeaveOneAtomicFinalSnapshotForARole() throws Exception { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RedisCapabilityObservationPort observations = + new MicrometerRedisCapabilityObservationPort(registry); + observations.observe( + new RedisCapabilityObservationEvent.AdmissionChanged( + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.AdmissionState.ADMITTED, + RedisCapabilityObservationEvent.InFlightState.IDLE, + 0, + 0)); + int workers = 8; + CyclicBarrier start = new CyclicBarrier(workers); + + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var futures = + java.util.stream.IntStream.range(0, workers) + .mapToObj( + worker -> + executor.submit( + () -> { + start.await(); + for (int iteration = 0; iteration < 2_000; iteration++) { + RedisCapabilityObservationEvent.InFlightState state = + ((worker + iteration) & 1) == 0 + ? RedisCapabilityObservationEvent.InFlightState.ACTIVE + : RedisCapabilityObservationEvent.InFlightState.SATURATED; + observations.observe( + new RedisCapabilityObservationEvent.AdmissionChanged( + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.AdmissionState.ADMITTED, + state, + state == RedisCapabilityObservationEvent.InFlightState.ACTIVE + ? 1 + : 2, + 1024)); + } + return null; + })) + .toList(); + for (var future : futures) { + future.get(); + } + } + + observations.observe( + new RedisCapabilityObservationEvent.AdmissionChanged( + RedisCapabilityObservationEvent.Role.COORDINATION, + RedisCapabilityObservationEvent.AdmissionState.ADMITTED, + RedisCapabilityObservationEvent.InFlightState.ACTIVE, + 1, + 1024)); + assertThat( + java.util.Arrays.stream(RedisCapabilityObservationEvent.InFlightState.values()) + .collect( + java.util.stream.Collectors.toMap( + state -> state, + state -> + registry + .get("redis.capability.inflight.total") + .tags( + "role", + "coordination", + "state", + state.name().toLowerCase(java.util.Locale.ROOT)) + .gauge() + .value()))) + .containsEntry(RedisCapabilityObservationEvent.InFlightState.ACTIVE, 1.0) + .containsEntry(RedisCapabilityObservationEvent.InFlightState.IDLE, 0.0) + .containsEntry(RedisCapabilityObservationEvent.InFlightState.SATURATED, 0.0); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RecordingRedisCapabilityObservations.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RecordingRedisCapabilityObservations.java new file mode 100644 index 0000000..9e77c86 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RecordingRedisCapabilityObservations.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +final class RecordingRedisCapabilityObservations implements RedisCapabilityObservationPort { + + private final Queue events = new ConcurrentLinkedQueue<>(); + + @Override + public void observe(RedisCapabilityObservationEvent.Event event) { + events.add(event); + } + + List operations() { + return events.stream() + .filter(RedisCapabilityObservationEvent.OperationCompleted.class::isInstance) + .map(RedisCapabilityObservationEvent.OperationCompleted.class::cast) + .toList(); + } + + List events() { + return List.copyOf(events); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java index 1de3ba3..50cf6a9 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisAtomicPrimitivesTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; +import java.util.Base64; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -51,9 +52,94 @@ class RedisAtomicPrimitivesTest { .hasMessageContaining("NEW_SERVER_STATUS"); } + @Test + void passesOnlyTheObservedDigestAndTheReplacementEnvelopeToTheAtomicProgram() { + CapturingExecutor executor = new CapturingExecutor("REPLACED"); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + byte[] digest = new byte[32]; + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + + assertThat( + primitives.replaceIfObservedWithTtl( + "cache-key", + token, + "replacement-envelope".getBytes(UTF_8), + Duration.ofSeconds(5), + "cache-region-v2")) + .isEqualTo(RedisAtomicPrimitives.ReplaceIfObservedResult.REPLACED); + assertThat(executor.programId).isEqualTo(RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL); + assertThat(executor.arguments.getFirst()).containsExactly(digest); + assertThat(executor.arguments.get(1)).containsExactly("replacement-envelope".getBytes(UTF_8)); + } + + @Test + void mapsGenerationInitializationAndBumpThroughBoundedTypedPrograms() { + CapturingExecutor executor = new CapturingExecutor("INITIALIZED"); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + + assertThat(primitives.initializeGeneration("generation-key", "AAAAAAAAAAAAAAAAAAAAAA")) + .isEqualTo(RedisAtomicPrimitives.GenerationInitResult.INITIALIZED); + assertThat(executor.programId).isEqualTo(RedisProgramId.REGION_GENERATION_INIT); + assertThat(executor.arguments) + .containsExactly("AAAAAAAAAAAAAAAAAAAAAA".getBytes(UTF_8), "0".getBytes(UTF_8)); + + executor.status = "BUMPED"; + assertThat( + primitives.bumpGeneration( + "generation-key", "BBBBBBBBBBBBBBBBBBBBBB", "CCCCCCCCCCCCCCCCCCCCCC")) + .isEqualTo(RedisAtomicPrimitives.GenerationBumpResult.BUMPED); + assertThat(executor.programId).isEqualTo(RedisProgramId.REGION_GENERATION_BUMP); + assertThat(executor.arguments) + .containsExactly( + "BBBBBBBBBBBBBBBBBBBBBB".getBytes(UTF_8), + "CCCCCCCCCCCCCCCCCCCCCC".getBytes(UTF_8), + "0".getBytes(UTF_8)); + } + + @Test + void mapsRefreshClaimThroughTheTypedFacadeWithoutRawCommands() { + CapturingExecutor executor = new CapturingExecutor("ALREADY_OWNED"); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + + assertThat( + primitives.claimRefreshLease( + "refresh-key", + "AAAAAAAAAAAAAAAAAAAAAA", + "BBBBBBBBBBBBBBBBBBBBBB", + Duration.ofSeconds(10))) + .isEqualTo(RedisAtomicPrimitives.RefreshClaimResult.ALREADY_OWNED); + assertThat(executor.programId).isEqualTo(RedisProgramId.CACHE_REFRESH_CLAIM); + assertThat(executor.arguments) + .containsExactly( + "AAAAAAAAAAAAAAAAAAAAAA".getBytes(UTF_8), + "BBBBBBBBBBBBBBBBBBBBBB".getBytes(UTF_8), + "10000".getBytes(UTF_8)); + } + + @Test + void rejectsARefreshLeaseLongerThanTheTemplateWideFiveMinuteBound() { + CapturingExecutor executor = new CapturingExecutor("CLAIMED"); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + + assertThatThrownBy( + () -> + primitives.claimRefreshLease( + "refresh-key", + "AAAAAAAAAAAAAAAAAAAAAA", + "BBBBBBBBBBBBBBBBBBBBBB", + Duration.ofMinutes(5).plusMillis(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("refresh lease TTL"); + assertThat(executor.calls).hasValue(0); + } + private static final class CapturingExecutor implements RedisProgramExecutor { - private final String status; + private String status; private final AtomicInteger calls = new AtomicInteger(); private RedisProgramId programId; private List keys; @@ -64,12 +150,11 @@ class RedisAtomicPrimitivesTest { } @Override - public String execute( - RedisProgramDescriptor descriptor, List keys, List arguments) { + public String execute(RedisCatalogProgramInvocation invocation) { calls.incrementAndGet(); - programId = descriptor.id(); - this.keys = keys; - this.arguments = arguments; + programId = invocation.descriptor().id(); + this.keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + this.arguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); return status; } } diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodecTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodecTest.java new file mode 100644 index 0000000..03e3ea3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisBoundedByteArrayCodecTest.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.ByteBuffer; +import org.junit.jupiter.api.Test; + +class RedisBoundedByteArrayCodecTest { + + @Test + void rejectsAnOversizedBulkReplyBeforeAllocatingTheDestinationArray() { + RedisBoundedByteArrayCodec codec = new RedisBoundedByteArrayCodec(1024); + + assertThat(codec.decodeValue(ByteBuffer.wrap(new byte[1024]))).hasSize(1024); + assertThatThrownBy(() -> codec.decodeValue(ByteBuffer.wrap(new byte[1025]))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("bound"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStoreTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStoreTest.java new file mode 100644 index 0000000..7cae857 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheConsistencyStoreTest.java @@ -0,0 +1,257 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.cache.CacheWriteCondition; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class RedisCacheConsistencyStoreTest { + + @Test + void capturesWinnerGenerationAndPerKeyRevisionAndRoundTripsAnOpaqueCondition() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + RedisCacheConsistencyStore store = + store(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB", "CCCCCCCCCCCCCCCCCCCCCC"); + + RedisCacheConsistencyStore.Snapshot first = store.capture("region-generation", "key-revision"); + RedisCacheConsistencyStore.Snapshot second = store.capture("region-generation", "key-revision"); + + assertThat(first).isEqualTo(second); + assertThat(first.generation()).isEqualTo("AAAAAAAAAAAAAAAAAAAAAA"); + assertThat(first.keyRevision()).isEqualTo("BBBBBBBBBBBBBBBBBBBBBB"); + CacheWriteCondition condition = first.toWriteCondition(); + assertThat(condition.usable()).isTrue(); + assertThat(store.decode(condition)).isEqualTo(first); + assertThat(condition.toString()).doesNotContain(first.generation()); + assertThat(redis.values).hasSize(2); + } + + @Test + void keyInvalidationAndMassInvalidationAdvanceIndependentFences() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + RedisCacheConsistencyStore store = + store( + redis, + "AAAAAAAAAAAAAAAAAAAAAA", + "BBBBBBBBBBBBBBBBBBBBBB", + "CCCCCCCCCCCCCCCCCCCCCC", + "DDDDDDDDDDDDDDDDDDDDDD", + "EEEEEEEEEEEEEEEEEEEEEE", + "FFFFFFFFFFFFFFFFFFFFFF"); + RedisCacheConsistencyStore.Snapshot before = store.capture("region-generation", "key-revision"); + + assertThat(store.bumpKeyRevision("key-revision", "DDDDDDDDDDDDDDDDDDDDDD")) + .isEqualTo(RedisCacheConsistencyStore.BumpResult.BUMPED); + RedisCacheConsistencyStore.Snapshot afterKey = + store.capture("region-generation", "key-revision"); + assertThat(afterKey.generation()).isEqualTo(before.generation()); + assertThat(afterKey.keyRevision()).isNotEqualTo(before.keyRevision()); + + assertThat(store.bumpRegionGeneration("region-generation", "FFFFFFFFFFFFFFFFFFFFFF")) + .isEqualTo(RedisCacheConsistencyStore.BumpResult.BUMPED); + RedisCacheConsistencyStore.Snapshot afterRegion = + store.capture("region-generation", "key-revision"); + assertThat(afterRegion.generation()).isNotEqualTo(afterKey.generation()); + assertThat(afterRegion.keyRevision()).isEqualTo(afterKey.keyRevision()); + } + + @Test + void callerCanReplayTheSameOperationAfterAResponseLoss() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + RedisCacheConsistencyStore store = + store( + redis, + "AAAAAAAAAAAAAAAAAAAAAA", + "BBBBBBBBBBBBBBBBBBBBBB", + "CCCCCCCCCCCCCCCCCCCCCC", + "EEEEEEEEEEEEEEEEEEEEEE"); + RedisCacheConsistencyStore.Snapshot before = store.capture("region-generation", "key-revision"); + + assertThat(store.bumpKeyRevision("key-revision", "DDDDDDDDDDDDDDDDDDDDDD")) + .isEqualTo(RedisCacheConsistencyStore.BumpResult.BUMPED); + RedisCacheConsistencyStore.Snapshot afterFirstAttempt = + store.capture("region-generation", "key-revision"); + + assertThat(store.bumpKeyRevision("key-revision", "DDDDDDDDDDDDDDDDDDDDDD")) + .isEqualTo(RedisCacheConsistencyStore.BumpResult.ALREADY_APPLIED); + assertThat(store.capture("region-generation", "key-revision")).isEqualTo(afterFirstAttempt); + assertThat(afterFirstAttempt.keyRevision()).isNotEqualTo(before.keyRevision()); + } + + @Test + void malformedOrUnavailableConditionsNeverBecomeAnUnguardedWrite() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + RedisCacheConsistencyStore store = + store(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB"); + + assertThat(store.decode(CacheWriteCondition.unavailable())).isNull(); + assertThatThrownBy(() -> store.decode(new CacheWriteCondition("not-a-v1-condition"))) + .isInstanceOf(RedisProgramCompatibilityException.class); + } + + @Test + void malformedStoredControlStateIsACompatibilityFailure() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + redis.values.put("region-generation", "invalid|state".getBytes(UTF_8)); + RedisCacheConsistencyStore store = + store(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB"); + + assertThatThrownBy(() -> store.capture("region-generation", "key-revision")) + .isInstanceOf(RedisProgramCompatibilityException.class); + } + + @Test + void keyRevisionExpiryReinitializesToANewFenceAndCannotRevealAnOldNamespace() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + RedisCacheConsistencyStore store = + store(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB", "CCCCCCCCCCCCCCCCCCCCCC"); + RedisCacheConsistencyStore.Snapshot old = store.capture("region-generation", "key-revision"); + + assertThat(redis.timeToLiveMillis.get("region-generation")).isZero(); + assertThat(redis.timeToLiveMillis.get("key-revision")) + .isEqualTo(Duration.ofDays(30).toMillis()); + + redis.expire("key-revision"); + RedisCacheConsistencyStore.Snapshot afterExpiry = + store.capture("region-generation", "key-revision"); + + assertThat(afterExpiry.generation()).isEqualTo(old.generation()); + assertThat(afterExpiry.keyRevision()).isNotEqualTo(old.keyRevision()); + assertThat(afterExpiry.toWriteCondition()).isNotEqualTo(old.toWriteCondition()); + } + + @Test + void evictedRegionGenerationReinitializesRandomlyInsteadOfResettingToAnOldNamespace() { + InMemoryGenerationRedis redis = new InMemoryGenerationRedis(); + RedisCacheConsistencyStore store = + store(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB", "CCCCCCCCCCCCCCCCCCCCCC"); + RedisCacheConsistencyStore.Snapshot old = store.capture("region-generation", "key-revision"); + + redis.expire("region-generation"); + RedisCacheConsistencyStore.Snapshot afterEviction = + store.capture("region-generation", "key-revision"); + + assertThat(afterEviction.generation()).isNotEqualTo(old.generation()); + assertThat(afterEviction.keyRevision()).isEqualTo(old.keyRevision()); + assertThat(afterEviction.toWriteCondition()).isNotEqualTo(old.toWriteCondition()); + assertThat(redis.timeToLiveMillis.get("region-generation")).isZero(); + } + + private static RedisCacheConsistencyStore store( + InMemoryGenerationRedis redis, String... identifiers) { + Queue values = new ArrayDeque<>(List.of(identifiers)); + Supplier identifiersSupplier = + () -> { + String value = values.poll(); + if (value == null) { + throw new AssertionError("test identifier supply exhausted"); + } + return value; + }; + return new RedisCacheConsistencyStore( + redis, + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), redis), + identifiersSupplier); + } + + private static final class InMemoryGenerationRedis + implements RedisBinaryCommands, RedisProgramExecutor { + + private final Map values = new HashMap<>(); + private final Map timeToLiveMillis = new HashMap<>(); + + @Override + public String execute(RedisCatalogProgramInvocation invocation) { + RedisProgramDescriptor descriptor = invocation.descriptor(); + List keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + List arguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); + String key = new String(keys.getFirst(), UTF_8); + if (descriptor.id() == RedisProgramId.REGION_GENERATION_INIT) { + if (values.containsKey(key)) { + timeToLiveMillis.put(key, ttl(arguments.get(1))); + return "EXISTING"; + } + values.put(key, state(arguments.getFirst(), "-".getBytes(UTF_8))); + timeToLiveMillis.put(key, ttl(arguments.get(1))); + return "INITIALIZED"; + } + if (descriptor.id() == RedisProgramId.REGION_GENERATION_BUMP) { + byte[] operation = arguments.get(1); + byte[] current = values.get(key); + if (current != null && Arrays.equals(operation, operation(current))) { + timeToLiveMillis.put(key, ttl(arguments.get(2))); + return "ALREADY_APPLIED"; + } + values.put(key, state(arguments.getFirst(), operation)); + timeToLiveMillis.put(key, ttl(arguments.get(2))); + return "BUMPED"; + } + throw new AssertionError("unexpected program " + descriptor.id()); + } + + @Override + public byte[] get(RedisPhysicalKey key) { + byte[] value = values.get(new String(RedisPhysicalKey.WireCodec.copy(key), UTF_8)); + return value == null ? null : value.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + throw new UnsupportedOperationException(); + } + + @Override + public long delete(RedisPhysicalKey key) { + throw new UnsupportedOperationException(); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + throw new UnsupportedOperationException(); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + private static byte[] state(byte[] generation, byte[] operation) { + byte[] state = new byte[generation.length + 1 + operation.length]; + System.arraycopy(generation, 0, state, 0, generation.length); + state[generation.length] = '|'; + System.arraycopy(operation, 0, state, generation.length + 1, operation.length); + return state; + } + + private static byte[] operation(byte[] state) { + int separator = -1; + for (int index = 0; index < state.length; index++) { + if (state[index] == '|') { + separator = index; + break; + } + } + return Arrays.copyOfRange(state, separator + 1, state.length); + } + + private void expire(String key) { + values.remove(key); + timeToLiveMillis.remove(key); + } + + private static long ttl(byte[] value) { + return Long.parseLong(new String(value, UTF_8)); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessageTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessageTest.java new file mode 100644 index 0000000..979b0d6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheInvalidationMessageTest.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class RedisCacheInvalidationMessageTest { + + private static final byte[] SECRET = + "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); + + @Test + void signedCodecRoundTripsOpaqueKeyAndRegionHints() { + RedisCacheInvalidationMessage.Codec codec = new RedisCacheInvalidationMessage.Codec(SECRET); + + String keyMessage = codec.encode(RedisCacheInvalidationMessage.key("opaque-hmac-key")); + String regionMessage = + codec.encode(RedisCacheInvalidationMessage.region("generation-aaaaaaaaa")); + + assertThat(codec.decode(keyMessage)) + .contains(RedisCacheInvalidationMessage.key("opaque-hmac-key")); + assertThat(codec.decode(regionMessage)) + .contains(RedisCacheInvalidationMessage.region("generation-aaaaaaaaa")); + } + + @Test + void rejectsTamperingMalformedInputAndOversizedPayloadsWithoutThrowing() { + RedisCacheInvalidationMessage.Codec codec = new RedisCacheInvalidationMessage.Codec(SECRET); + String valid = codec.encode(RedisCacheInvalidationMessage.key("opaque-hmac-key")); + String tampered = valid.substring(0, valid.length() - 1) + "A"; + + assertThat(codec.decode(tampered)).isEmpty(); + assertThat(codec.decode("not-a-message")).isEmpty(); + assertThat(codec.decode("x".repeat(4097))).isEmpty(); + } + + @Test + void ownedInputAndScopedSecretCopyAreZeroizedAcrossTheCodecLifecycle() { + byte[] ownedSecret = SECRET.clone(); + RedisCacheInvalidationMessage.Codec codec = + RedisCacheInvalidationMessage.Codec.fromOwnedSecret(ownedSecret); + + assertThat(ownedSecret).containsOnly(0); + assertThat(codec.destroyed()).isFalse(); + assertThat(codec.decode(codec.encode(RedisCacheInvalidationMessage.key("opaque")))).isPresent(); + + codec.close(); + codec.close(); + + assertThat(codec.destroyed()).isTrue(); + assertThatThrownBy(() -> codec.encode(RedisCacheInvalidationMessage.key("opaque"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("destroyed"); + Arrays.fill(ownedSecret, (byte) 1); + assertThatThrownBy(() -> codec.decode("v1.payload.signature")) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinatorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinatorTest.java new file mode 100644 index 0000000..68c4f66 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheRefreshCoordinatorTest.java @@ -0,0 +1,248 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; +import dev.caskeleton.application.cache.CacheRefreshClaimAttempt; +import dev.caskeleton.application.cache.CacheRefreshClaimOutcome; +import dev.caskeleton.application.cache.CacheRefreshReleaseOutcome; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class RedisCacheRefreshCoordinatorTest { + + @Test + void twoPodsShareOneOwnerAndOwnerCrashAllowsADuplicateOnlyAfterFiniteTtl() { + InMemoryRefreshRedis redis = new InMemoryRefreshRedis(); + RedisCacheRefreshCoordinator first = + coordinator(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB"); + RedisCacheRefreshCoordinator second = + coordinator(redis, "CCCCCCCCCCCCCCCCCCCCCC", "DDDDDDDDDDDDDDDDDDDDDD"); + CacheRefreshClaimAttempt firstAttempt = first.newAttempt(); + CacheRefreshClaimAttempt secondAttempt = second.newAttempt(); + + assertThat(first.claim("tenant-1:key", firstAttempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Claimed(firstAttempt)); + assertThat(second.claim("tenant-1:key", secondAttempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Contended()); + assertThat(redis.maximumOwners).isEqualTo(1); + + redis.advance(Duration.ofSeconds(10)); + + assertThat(second.claim("tenant-1:key", secondAttempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Claimed(secondAttempt)); + assertThat(redis.maximumOwners).isEqualTo(1); + assertThat(first.release("tenant-1:key", firstAttempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.NotOwner()); + assertThat(second.release("tenant-1:key", secondAttempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.Released()); + assertThat(redis.lastKey).doesNotContain("tenant-1"); + } + + @Test + void responseLossCanBeRetriedWithTheSameOperationWithoutRenewingTheLease() { + InMemoryRefreshRedis redis = new InMemoryRefreshRedis(); + redis.loseNextClaimResponse = true; + RedisCacheRefreshCoordinator coordinator = + coordinator(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB"); + CacheRefreshClaimAttempt attempt = coordinator.newAttempt(); + + assertThat(coordinator.claim("key", attempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Indeterminate()); + long originalExpiry = redis.expiry(); + + assertThat(coordinator.claim("key", attempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.AlreadyOwned(attempt)); + assertThat(redis.expiry()).isEqualTo(originalExpiry); + } + + @Test + void releaseIsExactOwnerSafeAndDistinguishesLeaseLoss() { + InMemoryRefreshRedis redis = new InMemoryRefreshRedis(); + RedisCacheRefreshCoordinator first = + coordinator(redis, "AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB"); + RedisCacheRefreshCoordinator other = + coordinator(redis, "CCCCCCCCCCCCCCCCCCCCCC", "DDDDDDDDDDDDDDDDDDDDDD"); + CacheRefreshClaimAttempt firstAttempt = first.newAttempt(); + CacheRefreshClaimAttempt otherAttempt = other.newAttempt(); + first.claim("key", firstAttempt, Duration.ofSeconds(10)); + + assertThat(other.release("key", otherAttempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.NotOwner()); + assertThat(first.release("key", firstAttempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.Released()); + assertThat(first.release("key", firstAttempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.AlreadyReleased()); + } + + @Test + void commandCertaintyMapsPreSendFailureAndResponseLossSeparately() { + FailingExecutor executor = new FailingExecutor(); + RedisAtomicPrimitives primitives = + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), executor); + RedisCacheRefreshCoordinator coordinator = + new RedisCacheRefreshCoordinator( + namespace(), + new byte[32], + primitives, + tokens("AAAAAAAAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBBBBBBBB")); + CacheRefreshClaimAttempt attempt = coordinator.newAttempt(); + + executor.certainty = RedisCommandFailureException.Certainty.NOT_APPLIED; + assertThat(coordinator.claim("key", attempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Unavailable()); + assertThat(coordinator.release("key", attempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.Unavailable()); + + executor.certainty = RedisCommandFailureException.Certainty.INDETERMINATE; + assertThat(coordinator.claim("key", attempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Indeterminate()); + assertThat(coordinator.release("key", attempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.Indeterminate()); + } + + private static RedisCacheRefreshCoordinator coordinator( + InMemoryRefreshRedis redis, String owner, String operation) { + return new RedisCacheRefreshCoordinator( + namespace(), + new byte[32], + new RedisAtomicPrimitives(RedisProgramCatalog.foundation(), redis), + tokens(owner, operation)); + } + + private static RedisKeyNamespace namespace() { + return new RedisKeyNamespace("ca-skeleton", "test", "cache", "worklog", 1, 1, "entry", 512); + } + + private static Supplier tokens(String... values) { + Queue queue = new ArrayDeque<>(List.of(values)); + return () -> { + String value = queue.poll(); + if (value == null) { + throw new AssertionError("test token supply exhausted"); + } + return value; + }; + } + + private static final class FailingExecutor implements RedisProgramExecutor { + + private RedisCommandFailureException.Certainty certainty; + + @Override + public String execute(RedisCatalogProgramInvocation invocation) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + certainty, + "simulated command failure", + null); + } + } + + private static final class InMemoryRefreshRedis implements RedisProgramExecutor { + + private final AtomicLong nowMillis = new AtomicLong(); + private final Map leases = new HashMap<>(); + private int maximumOwners; + private boolean loseNextClaimResponse; + private String lastKey; + + @Override + public synchronized String execute(RedisCatalogProgramInvocation invocation) { + RedisProgramDescriptor descriptor = invocation.descriptor(); + List keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + List arguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); + String key = new String(keys.getFirst(), UTF_8); + lastKey = key; + expire(key); + if (descriptor.id() == RedisProgramId.CACHE_REFRESH_CLAIM) { + byte[] ownerState = state(arguments.get(0), arguments.get(1)); + Lease current = leases.get(key); + if (current != null) { + return Arrays.equals(current.ownerState(), ownerState) ? "ALREADY_OWNED" : "CONTENDED"; + } + long ttl = Long.parseLong(new String(arguments.get(2), UTF_8)); + leases.put(key, new Lease(ownerState, nowMillis.get() + ttl)); + maximumOwners = Math.max(maximumOwners, leases.containsKey(key) ? 1 : 0); + if (loseNextClaimResponse) { + loseNextClaimResponse = false; + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "response lost", + null); + } + return "CLAIMED"; + } + if (descriptor.id() == RedisProgramId.COMPARE_AND_DELETE) { + Lease current = leases.get(key); + if (current == null) { + return "ABSENT"; + } + if (!Arrays.equals(current.ownerState(), arguments.getFirst())) { + return "NOT_OWNER"; + } + leases.remove(key); + return "DELETED"; + } + throw new AssertionError("unexpected program " + descriptor.id()); + } + + private synchronized void advance(Duration duration) { + nowMillis.addAndGet(duration.toMillis()); + } + + private synchronized long expiry() { + String physicalKey = + leases.keySet().stream() + .filter(key -> key.contains("refresh-lease")) + .findFirst() + .orElseThrow(); + expire(physicalKey); + return leases.get(physicalKey).expiresAtMillis(); + } + + private void expire(String key) { + Lease current = leases.get(key); + if (current != null && current.expiresAtMillis() <= nowMillis.get()) { + leases.remove(key); + } + } + + private static byte[] state(byte[] owner, byte[] operation) { + byte[] state = new byte[owner.length + 1 + operation.length]; + System.arraycopy(owner, 0, state, 0, owner.length); + state[owner.length] = '|'; + System.arraycopy(operation, 0, state, owner.length + 1, operation.length); + return state; + } + + private static final class Lease { + + private final byte[] ownerState; + private final long expiresAtMillis; + + private Lease(byte[] ownerState, long expiresAtMillis) { + this.ownerState = ownerState.clone(); + this.expiresAtMillis = expiresAtMillis; + } + + private byte[] ownerState() { + return ownerState.clone(); + } + + private long expiresAtMillis() { + return expiresAtMillis; + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfigTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfigTest.java new file mode 100644 index 0000000..c05f8a3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheConfigTest.java @@ -0,0 +1,196 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class RedisCanonicalCacheConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration(RedisCanonicalCacheConfig.class); + + @Test + void disabledBindingCreatesNoCacheRuntimeOrSubscription() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(RedisCacheRegionRuntime.class)).isEmpty(); + assertThat(context.getBeansOfType(RedisCacheInvalidationSubscription.class)).isEmpty(); + }); + } + + @Test + void selectedBindingWithoutCanonicalCacheRoleFailsWithoutReadingLegacySettings() { + runner + .withPropertyValues( + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference=secret://environment/CACHE_KEY_HMAC", + "app.cache.redis.host=must-not-be-read.invalid", + "app.cache.redis.password=must-not-be-read", + "app.cache.redis.key-hmac-secret=must-not-be-read") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf( + org.springframework.beans.factory.NoSuchBeanDefinitionException.class); + assertThat(context.getStartupFailure().getMessage()) + .doesNotContain("must-not-be-read"); + }); + } + + @Test + void selectedBindingUsesOnlyTheCanonicalCacheRouterForL2AndInvalidation() { + AtomicReference listener = new AtomicReference<>(); + CanonicalCacheRuntime runtime = new CanonicalCacheRuntime(listener); + RedisCanonicalRoleRegistry registry = registry(runtime); + byte[] secret = new byte[32]; + java.util.Arrays.fill(secret, (byte) 7); + char[] base64 = Base64.getEncoder().encodeToString(secret).toCharArray(); + java.util.Arrays.fill(secret, (byte) 0); + + runner + .withBean(RedisCanonicalRoleRegistry.class, () -> registry) + .withBean( + RedisCredentialMaterialProvider.class, + () -> + ignored -> + new VersionedRedisCredentialMaterial( + "test-v1", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from(base64))) + .withBean( + Clock.class, () -> Clock.fixed(Instant.parse("2026-07-29T00:00:00Z"), ZoneOffset.UTC)) + .withPropertyValues( + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference=secret://environment/CACHE_KEY_HMAC", + "ca-skeleton.capabilities.cache.regions.default.l1.enabled=true", + "app.cache.redis.host=must-not-be-read.invalid", + "app.cache.redis.password=must-not-be-read", + "app.cache.redis.key-hmac-secret=must-not-be-read") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(RedisCacheRegionRuntime.class); + assertThat(context).hasSingleBean(RedisCacheInvalidationSubscription.class); + assertThat(listener).doesNotHaveValue(null); + assertThat(runtime.subscribedChannel()) + .startsWith("ca:ca-skeleton:local:cache:default:"); + }); + + java.util.Arrays.fill(base64, '\0'); + } + + private static RedisCanonicalRoleRegistry registry(CanonicalCacheRuntime runtime) { + RedisClientRuntimeSettings clientSettings = + new RedisClientRuntimeSettings( + "cache-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + RedisDeploymentSettings.Standalone deployment = + new RedisDeploymentSettings.Standalone( + "cache-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "runtime", "secret://environment/CACHE_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/CACHE_TRUST_PEM")); + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.CACHE, deployment), + clientSettings, + 8, + 65_536, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + ignored -> runtime); + } + + private static final class CanonicalCacheRuntime implements RedisRoutableCommandRuntime { + + private final AtomicReference listener; + private String subscribedChannel; + + private CanonicalCacheRuntime(AtomicReference listener) { + this.listener = listener; + } + + private String subscribedChannel() { + return subscribedChannel; + } + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return "cache-main"; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + ? RedisCatalogProgramReply.multi(List.of()) + : RedisCatalogProgramReply.value(null); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 1; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener actualListener) { + subscribedChannel = new String(channel, java.nio.charset.StandardCharsets.US_ASCII); + listener.set(actualListener); + return () -> listener.compareAndSet(actualListener, null); + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettingsTest.java new file mode 100644 index 0000000..7ebe5b8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalCacheSettingsTest.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class RedisCanonicalCacheSettingsTest { + + @Test + void bindsTheCanonicalDefaultRegionWithoutLegacyConnectionOrRawSecretProperties() { + RedisCanonicalCacheSettings settings = + new Binder( + new MapConfigurationPropertySource( + Map.ofEntries( + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference", + "secret://environment/CACHE_KEY_HMAC"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.namespace-application", + "orders"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.namespace-environment", + "production"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.semantic-region", + "catalog"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.positive-soft-ttl", + "20s"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.positive-hard-ttl", + "30s"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.l1.enabled", "true"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.l1.maximum-entries", + "512"), + Map.entry( + "ca-skeleton.capabilities.cache.regions.default.l1.maximum-weight-bytes", + "1048576")))) + .bind( + "ca-skeleton.capabilities.cache.regions.default", + Bindable.of(RedisCanonicalCacheSettings.class)) + .orElseThrow(() -> new AssertionError("canonical cache settings did not bind")); + + settings.validateActive(); + + assertThat(settings.keyHmacSecretReference()).isEqualTo("secret://environment/CACHE_KEY_HMAC"); + assertThat(settings.namespaceApplication()).isEqualTo("orders"); + assertThat(settings.namespaceEnvironment()).isEqualTo("production"); + assertThat(settings.semanticRegion()).isEqualTo("catalog"); + assertThat(settings.positiveSoftTtl()).isEqualTo(Duration.ofSeconds(20)); + assertThat(settings.positiveHardTtl()).isEqualTo(Duration.ofSeconds(30)); + assertThat(settings.l1().enabled()).isTrue(); + assertThat(settings.l1().policy().maximumEntries()).isEqualTo(512); + } + + @Test + void inactiveDefaultsRemainBindableButActiveBindingRequiresASecretReference() { + RedisCanonicalCacheSettings settings = + new RedisCanonicalCacheSettings( + null, null, null, null, 0, 0, null, null, null, null, null, 0, null); + + assertThatThrownBy(settings::validateActive) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + @Test + void rejectsLocalGenerationReconciliationThatExceedsTheLocalTtl() { + assertThatThrownBy( + () -> + new RedisCanonicalCacheSettings.LocalProperties( + true, 10, 1024, 512, Duration.ofSeconds(5), Duration.ofSeconds(6), 16)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("generationRecheckInterval"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfigTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfigTest.java new file mode 100644 index 0000000..35dc9ae --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalConfigTest.java @@ -0,0 +1,343 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.mock.env.MockEnvironment; + +class RedisCanonicalConfigTest { + + @Test + void composesOneSafeRedisObservationPortWithOptionalMicrometerRendering() { + SimpleMeterRegistry meters = new SimpleMeterRegistry(); + runner(new AtomicInteger(), new AtomicInteger()) + .withBean(SimpleMeterRegistry.class, () -> meters) + .run( + context -> { + RedisCapabilityObservationPort observations = + context.getBean(RedisCapabilityObservationPort.class); + observations.observe( + new RedisCapabilityObservationEvent.LifecycleDrainCompleted( + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.DrainOutcome.DRAINED)); + + assertThat( + meters + .get("redis.capability.lifecycle.drain.total") + .tags("role", "cache", "drain_outcome", "drained") + .counter() + .count()) + .isEqualTo(1); + }); + } + + @Test + void sessionCapabilityUsesOnlyTheCanonicalSecurityModeKey() { + var canonical = + new MockEnvironment() + .withProperty("ca-skeleton.security.auth-mode", "redis-session") + .withProperty("app.security.auth-mode", ""); + var wrongAliasOnly = + new MockEnvironment().withProperty("app.security.auth-mode", "redis-session"); + + assertThat(RedisCanonicalConfig.selectedCapabilities(canonical).get(RedisRole.SESSION)) + .containsExactly(RedisHealthSnapshotProvider.Capability.SESSION); + assertThat(RedisCanonicalConfig.selectedCapabilities(wrongAliasOnly).get(RedisRole.SESSION)) + .isEmpty(); + } + + @Test + void providerDefinitionWithoutRoleBindingResolvesNoMaterialAndOpensNoRuntime() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + + runner(credentialResolutions, trustResolutions) + .withPropertyValues( + "ca-skeleton.providers.redis.deployments.cache-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].host=cache.internal", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].port=6380", + "ca-skeleton.providers.redis.deployments.cache-main.database=0", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.username=runtime", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.password-reference=secret://environment/APP_CACHE_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.cache-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.trust-bundle-reference=secret://environment/APP_CACHE_REDIS_TRUST_PEM") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(RedisCanonicalRoleRegistry.class).boundRoles()).isEmpty(); + assertThat(context.getBean(RedisHealthSnapshotProvider.class).snapshot().roles()) + .isEmpty(); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + @Test + void declaredButUnselectedRolesResolveNoMaterialAndOpenNoRuntime() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + + runner(credentialResolutions, trustResolutions) + .withPropertyValues( + properties( + standaloneDeployment("cache-main"), + standaloneDeployment("coord-main"), + standaloneDeployment("session-main"), + new String[] { + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main", + "ca-skeleton.providers.redis.roles.cache.required=false", + "ca-skeleton.providers.redis.roles.cache.expected-eviction=allkeys-lfu", + "ca-skeleton.providers.redis.roles.coordination.deployment-id=coord-main", + "ca-skeleton.providers.redis.roles.coordination.required=true", + "ca-skeleton.providers.redis.roles.coordination.expected-eviction=noeviction", + "ca-skeleton.providers.redis.roles.session.deployment-id=session-main", + "ca-skeleton.providers.redis.roles.session.required=true", + "ca-skeleton.providers.redis.roles.session.expected-eviction=noeviction" + })) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(RedisCanonicalRoleRegistry.class).boundRoles()).isEmpty(); + assertThat(context.getBean(RedisHealthSnapshotProvider.class).snapshot().roles()) + .isEmpty(); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + @Test + void selectedCapabilityWithoutItsRoleBindingFailsBeforeMaterialResolution() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + + runner(credentialResolutions, trustResolutions) + .withPropertyValues("ca-skeleton.capabilities.rate-limit.provider=redis") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasMessageContaining("COORDINATION"); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + @Test + void roleBindingWithoutExpectedEvictionFailsAtStartupBeforeMaterialResolution() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + + runner(credentialResolutions, trustResolutions) + .withPropertyValues( + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.providers.redis.deployments.cache-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].host=cache.internal", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].port=6380", + "ca-skeleton.providers.redis.deployments.cache-main.database=0", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.username=runtime", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.password-reference=secret://environment/APP_CACHE_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.cache-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.trust-bundle-reference=secret://environment/APP_CACHE_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main", + "ca-skeleton.providers.redis.roles.cache.required=false") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expected eviction"); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + @Test + void selectedSentinelUsesSplitRefreshConnectorWithoutResolvingDefaultRuntimeMaterial() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + AtomicInteger discoveries = new AtomicInteger(); + AtomicInteger dataConnects = new AtomicInteger(); + + runner(credentialResolutions, trustResolutions) + .withPropertyValues( + properties( + sentinelDeployment("cache-main"), + new String[] { + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main", + "ca-skeleton.providers.redis.roles.cache.required=false", + "ca-skeleton.providers.redis.roles.cache.expected-eviction=allkeys-lfu" + })) + .withBean( + RedisRuntimeConnector.class, + () -> + deployment -> { + throw new AssertionError("generic connector must not open Sentinel"); + }) + .withBean( + RedisSentinelRuntimeConnector.class, + () -> + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings + .Sentinel + deployment) { + discoveries.incrementAndGet(); + return RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint( + "redis-primary.internal", 6379)); + } + + @Override + public RedisRoutableCommandRuntime connect( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings + .Sentinel + deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + dataConnects.incrementAndGet(); + throw new RedisTemporaryConnectionException(); + } + }) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(RedisCanonicalRoleRegistry.class).boundRoles()) + .containsExactly(RedisRole.CACHE); + assertThat(discoveries).hasValue(1); + assertThat(dataConnects).hasValue(1); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + private static ApplicationContextRunner runner( + AtomicInteger credentialResolutions, AtomicInteger trustResolutions) { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration(RedisCanonicalConfig.class) + .withBean( + RedisCredentialMaterialProvider.class, + () -> + ignored -> { + credentialResolutions.incrementAndGet(); + throw new AssertionError("credential material must remain unresolved"); + }) + .withBean( + RedisTrustMaterialProvider.class, + () -> + ignored -> { + trustResolutions.incrementAndGet(); + throw new AssertionError("trust material must remain unresolved"); + }); + } + + private static String[] standaloneDeployment(String deploymentId) { + return new String[] { + "ca-skeleton.providers.redis.deployments." + deploymentId + ".topology=standalone", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".standalone.endpoints[0].host=" + + deploymentId + + ".internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".standalone.endpoints[0].port=6380", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".database=0", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".authentication.username=runtime", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".authentication.password-reference=secret://environment/APP_CACHE_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".tls.enabled=true", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".tls.trust-bundle-reference=secret://environment/APP_CACHE_REDIS_TRUST_PEM" + }; + } + + private static String[] sentinelDeployment(String deploymentId) { + return new String[] { + "ca-skeleton.providers.redis.deployments." + deploymentId + ".topology=sentinel", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.master-name=cache-master", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.endpoints[0].host=sentinel-a.internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.endpoints[0].port=26379", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.endpoints[1].host=sentinel-b.internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.endpoints[1].port=26379", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.endpoints[2].host=sentinel-c.internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.endpoints[2].port=26379", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.data-endpoints[0].host=redis-primary.internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.data-endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.data-endpoints[1].host=redis-replica-a.internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.data-endpoints[1].port=6379", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.data-endpoints[2].host=redis-replica-b.internal", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.data-endpoints[2].port=6379", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.authentication.username=sentinel", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.authentication.password-reference=secret://environment/SENTINEL_PASSWORD", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".sentinel.tls.enabled=true", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".sentinel.tls.trust-bundle-reference=secret://environment/SENTINEL_TRUST_PEM", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".database=0", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".authentication.username=data", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".authentication.password-reference=secret://environment/REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".tls.enabled=true", + "ca-skeleton.providers.redis.deployments." + deploymentId + ".tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments." + + deploymentId + + ".tls.trust-bundle-reference=secret://environment/REDIS_TRUST_PEM" + }; + } + + private static String[] properties(String[]... groups) { + return java.util.Arrays.stream(groups).flatMap(java.util.Arrays::stream).toArray(String[]::new); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleHealthSnapshotTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleHealthSnapshotTest.java new file mode 100644 index 0000000..6bb6ccc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleHealthSnapshotTest.java @@ -0,0 +1,436 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.EvictionAttestation; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.EvictionPolicy; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Role; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; +import org.junit.jupiter.api.Test; + +class RedisCanonicalRoleHealthSnapshotTest { + + private static final Instant OBSERVED_AT = Instant.parse("2026-07-29T01:02:03Z"); + private static final Clock CLOCK = Clock.fixed(OBSERVED_AT, ZoneOffset.UTC); + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "health-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void readinessObservationUsesTheExactReturnedSanitizedRoleHealth() { + ProbeRuntime runtime = new ProbeRuntime("cache-main"); + RedisRoleBinding binding = new RedisRoleBinding("cache-main", false, "allkeys-lfu"); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.CACHE, standalone(runtime.deploymentId())), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> runtime, + Map.of(RedisRole.CACHE, binding), + Map.of(RedisRole.CACHE, Set.of(Capability.CACHE)), + CLOCK, + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + observations)) { + var health = registry.snapshot().roles().getFirst(); + + assertThat(observations.events()) + .filteredOn(RedisCapabilityObservationEvent.ReadinessObserved.class::isInstance) + .map(RedisCapabilityObservationEvent.ReadinessObserved.class::cast) + .singleElement() + .satisfies( + event -> { + assertThat(event.capability()) + .isEqualTo(RedisCapabilityObservationEvent.Capability.CACHE); + assertThat(event.role()).isEqualTo(RedisCapabilityObservationEvent.Role.CACHE); + assertThat(event.state()).isSameAs(health.state()); + assertThat(event.reason()).isSameAs(health.reason()); + assertThat(event.requirement()) + .isEqualTo(RedisCapabilityObservationEvent.Requirement.OPTIONAL); + }); + } + } + + @Test + void reportsBoundOptionalCacheCapabilityWithoutClaimingRuntimeEvictionAttestation() { + ProbeRuntime runtime = new ProbeRuntime("cache-main"); + RedisRoleBinding binding = new RedisRoleBinding("cache-main", false, "allkeys-lfu"); + try (RedisCanonicalRoleRegistry registry = + registry( + RedisRole.CACHE, binding, runtime, Map.of(RedisRole.CACHE, Set.of(Capability.CACHE)))) { + var snapshot = registry.snapshot(); + + assertThat(snapshot.observedAt()).isEqualTo(OBSERVED_AT); + assertThat(snapshot.roles()) + .singleElement() + .satisfies( + health -> { + assertThat(health.role()).isEqualTo(Role.CACHE); + assertThat(health.deploymentId()).isEqualTo("cache-main"); + assertThat(health.required()).isFalse(); + assertThat(health.expectedEviction()).isEqualTo(EvictionPolicy.ALLKEYS_LFU); + assertThat(health.evictionAttestation()) + .isEqualTo(EvictionAttestation.CONFIGURED_EXPECTATION_ONLY); + assertThat(health.capabilities()).containsExactly(Capability.CACHE); + assertThat(health.state()).isEqualTo(State.AVAILABLE); + assertThat(health.reason()).isEqualTo(Reason.SEMANTIC_PROBE_SUCCEEDED); + assertThat(health.semanticObservedAt()).isEqualTo(OBSERVED_AT); + assertThat(health.semanticAgeMillis()).isZero(); + assertThat(health.semanticStale()).isFalse(); + }); + assertThat(runtime.probes()).isEqualTo(1); + } + } + + @Test + void reportsRequiredCoordinationFailureWithOnlySanitizedReasonAndSelectedCapabilities() { + ProbeRuntime runtime = new ProbeRuntime("coord-main"); + RedisRoleBinding binding = new RedisRoleBinding("coord-main", true, "noeviction"); + AtomicLong ticker = new AtomicLong(); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + try (RedisCanonicalRoleRegistry registry = + registry( + RedisRole.COORDINATION, + binding, + runtime, + Map.of(RedisRole.COORDINATION, Set.of(Capability.RATE_LIMIT, Capability.IDEMPOTENCY)), + ticker::get, + observations)) { + runtime.failWith(new IllegalStateException("credential material must never leak")); + ticker.addAndGet(Duration.ofSeconds(6).toNanos()); + + var health = registry.snapshot().roles().getFirst(); + + assertThat(health.role()).isEqualTo(Role.COORDINATION); + assertThat(health.required()).isTrue(); + assertThat(health.expectedEviction()).isEqualTo(EvictionPolicy.NOEVICTION); + assertThat(health.capabilities()) + .containsExactlyInAnyOrder(Capability.RATE_LIMIT, Capability.IDEMPOTENCY); + assertThat(health.state()).isEqualTo(State.UNAVAILABLE); + assertThat(health.reason()).isEqualTo(Reason.COMMAND_UNAVAILABLE); + assertThat(health.toString()).doesNotContain("credential material"); + assertThat(observations.events()) + .filteredOn(RedisCapabilityObservationEvent.ReadinessObserved.class::isInstance) + .map(RedisCapabilityObservationEvent.ReadinessObserved.class::cast) + .hasSize(2) + .allSatisfy( + event -> { + assertThat(event.role()) + .isEqualTo(RedisCapabilityObservationEvent.Role.COORDINATION); + assertThat(event.state()).isSameAs(health.state()); + assertThat(event.reason()).isSameAs(health.reason()); + assertThat(event.requirement()) + .isEqualTo(RedisCapabilityObservationEvent.Requirement.REQUIRED); + }); + } + } + + @Test + void semanticContractMismatchIsStartupFatalBeforeRegistryPublication() { + ProbeRuntime runtime = new ProbeRuntime("coord-main"); + runtime.aclStatus("VERSION_UNSUPPORTED"); + + assertThatThrownBy( + () -> + registry( + RedisRole.COORDINATION, + new RedisRoleBinding("coord-main", true, "noeviction"), + runtime, + Map.of( + RedisRole.COORDINATION, + Set.of(Capability.RATE_LIMIT, Capability.IDEMPOTENCY)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SERVER_VERSION_UNSUPPORTED") + .hasMessageNotContaining("credential") + .hasMessageNotContaining("password"); + } + + @Test + void cachedSuccessIsImmediatelyInvalidatedByRecentFailureAndRouteClose() { + ProbeRuntime runtime = new ProbeRuntime("cache-main"); + try (RedisCanonicalRoleRegistry registry = + registry( + RedisRole.CACHE, + new RedisRoleBinding("cache-main", false, "allkeys-lfu"), + runtime, + Map.of(RedisRole.CACHE, Set.of(Capability.CACHE)))) { + runtime.failWith( + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "raw failure must not leak", + null)); + assertThatThrownBy(() -> registry.probe(RedisRole.CACHE)) + .isInstanceOf(RedisCommandFailureException.class); + + var recent = registry.snapshot().roles().getFirst(); + assertThat(recent.reason()).isEqualTo(Reason.RECENT_COMMAND_FAILURE); + assertThat(recent.state()).isEqualTo(State.UNAVAILABLE); + + registry.router(RedisRole.CACHE).close(); + var closed = registry.snapshot().roles().getFirst(); + assertThat(closed.reason()).isEqualTo(Reason.ROUTE_CLOSED); + assertThat(closed.state()).isEqualTo(State.UNAVAILABLE); + } + } + + @Test + void rotateRequiresFullSemanticQualificationAndSeedsSuccessfulCandidateObservation() { + ProbeRuntime initial = new ProbeRuntime("cache-main"); + try (RedisCanonicalRoleRegistry registry = + registry( + RedisRole.CACHE, + new RedisRoleBinding("cache-main", false, "allkeys-lfu"), + initial, + Map.of(RedisRole.CACHE, Set.of(Capability.CACHE)))) { + ProbeRuntime incompatible = new ProbeRuntime("cache-next"); + incompatible.aclStatus("VERSION_UNSUPPORTED"); + + assertThat(registry.rotate(RedisRole.CACHE, incompatible)) + .isEqualTo(RedisRoleCommandRouter.SwapResult.PROBE_FAILED); + assertThat(incompatible.closed()).isTrue(); + assertThat(initial.closed()).isFalse(); + + ProbeRuntime compatible = new ProbeRuntime("cache-next"); + assertThat(registry.rotate(RedisRole.CACHE, compatible)) + .isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + assertThat(initial.closed()).isTrue(); + assertThat(registry.snapshot().roles().getFirst().reason()) + .isEqualTo(Reason.SEMANTIC_PROBE_SUCCEEDED); + assertThat(compatible.probes()).isEqualTo(2); + } + } + + @Test + void noRoleBindingsProduceAnEmptySnapshotAndNoRuntime() { + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of(), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + throw new AssertionError("no runtime may be created"); + })) { + var snapshot = registry.snapshot(); + + assertThat(snapshot.roles()).isEmpty(); + } + } + + private static RedisCanonicalRoleRegistry registry( + RedisRole role, + RedisRoleBinding binding, + ProbeRuntime runtime, + Map> capabilities) { + return registry(role, binding, runtime, capabilities, System::nanoTime); + } + + private static RedisCanonicalRoleRegistry registry( + RedisRole role, + RedisRoleBinding binding, + ProbeRuntime runtime, + Map> capabilities, + LongSupplier ticker) { + return registry( + role, + binding, + runtime, + capabilities, + ticker, + NoOpRedisCapabilityObservationPort.instance()); + } + + private static RedisCanonicalRoleRegistry registry( + RedisRole role, + RedisRoleBinding binding, + ProbeRuntime runtime, + Map> capabilities, + LongSupplier ticker, + RedisCapabilityObservationPort observations) { + return new RedisCanonicalRoleRegistry( + Map.of(role, standalone(runtime.deploymentId())), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> runtime, + Map.of(role, binding), + capabilities, + CLOCK, + Duration.ofSeconds(5), + Duration.ofSeconds(15), + ticker, + observations); + } + + private static RedisDeploymentSettings.Standalone standalone(String id) { + return new RedisDeploymentSettings.Standalone( + id, + 0, + List.of(new RedisDeploymentSettings.Endpoint(id + ".internal", 6379)), + new RedisDeploymentSettings.Authentication( + "runtime", "secret://environment/REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/REDIS_TRUST_PEM")); + } + + private static final class ProbeRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final AtomicReference failure = new AtomicReference<>(); + private final Map values = new HashMap<>(); + private final Set loaded = new HashSet<>(); + private final Map programsBySha = new HashMap<>(); + private String aclStatus = "ACL_OK"; + private boolean closed; + private int probes; + + private ProbeRuntime(String deploymentId) { + this.deploymentId = deploymentId; + RedisProgramCatalog.unified() + .descriptors() + .forEach( + descriptor -> + programsBySha.put( + RedisScriptRecovery.sha1(descriptor.scriptBytes()), descriptor.id())); + } + + private int probes() { + return probes; + } + + private void failWith(RuntimeException exception) { + failure.set(exception); + } + + private void aclStatus(String status) { + aclStatus = status; + } + + private boolean closed() { + return closed; + } + + @Override + public void probe(Duration timeout) { + probes++; + RuntimeException exception = failure.get(); + if (exception != null) { + throw exception; + } + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + byte[] value = + values.get( + new String( + RedisPhysicalKey.WireCodec.copy(key), java.nio.charset.StandardCharsets.UTF_8)); + return value == null ? null : value.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + values.put( + new String(RedisPhysicalKey.WireCodec.copy(key), java.nio.charset.StandardCharsets.UTF_8), + value.copyEncoded()); + } + + @Override + public long delete(RedisPhysicalKey key) { + return values.remove( + new String( + RedisPhysicalKey.WireCodec.copy(key), + java.nio.charset.StandardCharsets.UTF_8)) + == null + ? 0 + : 1; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + String sha1 = invocation.sha1(); + if (!loaded.contains(sha1)) { + throw new RedisNoScriptException(); + } + if (sha1.equals(RedisScriptRecovery.sha1(RedisSemanticAclProbeCatalog.scriptBytes()))) { + return RedisCatalogProgramReply.value( + aclStatus.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + RedisProgramId id = programsBySha.get(sha1); + if (invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.MULTI) { + return RedisCatalogProgramReply.value( + "EXISTS".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + RedisProgramDescriptor descriptor = RedisProgramCatalog.unified().descriptor(id); + String status = + switch (id) { + case RATE_FIXED_WINDOW_V2, IDEMPOTENCY_CLAIM_V1, LEASE_ACQUIRE_V1 -> + "STATE_INCOMPATIBLE"; + case SESSION_CREATE_V1 -> "TOMBSTONED"; + default -> throw new AssertionError("unexpected structured semantic program: " + id); + }; + java.util.ArrayList reply = new java.util.ArrayList<>(); + reply.add(status.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + while (reply.size() < descriptor.replyFieldCount()) { + reply.add(new byte[0]); + } + return RedisCatalogProgramReply.multi(List.copyOf(reply)); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + loaded.add(invocation.sha1()); + return invocation.sha1(); + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistryTest.java new file mode 100644 index 0000000..a35ba3a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCanonicalRoleRegistryTest.java @@ -0,0 +1,789 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RedisCanonicalRoleRegistryTest { + + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "canonical-redis", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void providerDefinitionsWithoutRoleBindingsCreateNoRuntimeSideEffects() { + AtomicInteger runtimeBuilds = new AtomicInteger(); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of(), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + runtimeBuilds.incrementAndGet(); + return new RouterFakeRuntime(deployment.deploymentId()); + })) { + + assertThat(registry.boundRoles()).isEmpty(); + assertThat(runtimeBuilds).hasValue(0); + } + } + + @Test + void createsOnlyRoleBoundRoutersAndKeepsCacheAndCoordinationSeparate() { + AtomicInteger runtimeBuilds = new AtomicInteger(); + Map active = + Map.of( + RedisRole.CACHE, standalone("cache-main"), + RedisRole.COORDINATION, standalone("coord-main")); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + active, + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + runtimeBuilds.incrementAndGet(); + return new RouterFakeRuntime(deployment.deploymentId()); + })) { + + assertThat(registry.boundRoles()) + .containsExactlyInAnyOrder(RedisRole.CACHE, RedisRole.COORDINATION); + assertThat(registry.router(RedisRole.CACHE).read("key")).contains("cache-main"); + assertThat(registry.router(RedisRole.COORDINATION).read("key")).contains("coord-main"); + assertThat(runtimeBuilds).hasValue(2); + } + } + + @Test + void selectedSentinelWithoutSplitRefreshConnectorFailsClosedBeforeRuntimeOpen() { + AtomicInteger runtimeBuilds = new AtomicInteger(); + + assertThatThrownBy( + () -> + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, sentinel()), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + runtimeBuilds.incrementAndGet(); + return new RouterFakeRuntime(deployment.deploymentId()); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Sentinel refresh connector"); + assertThat(runtimeBuilds).hasValue(0); + } + + @Test + void selectedSentinelCreatesOneCoordinatorWorkerAndRecurringRoleTask() { + AtomicInteger runtimeBuilds = new AtomicInteger(); + CountingWorker worker = new CountingWorker(); + RedisSentinelRuntimeConnector sentinelConnector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + return route("redis-primary.internal"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + runtimeBuilds.incrementAndGet(); + return new RouterFakeRuntime(deployment.deploymentId(), discoveredRoute.identity()); + } + }; + + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, sentinel()), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + throw new AssertionError("generic connector must not open Sentinel"); + }, + Map.of(), + Map.of(), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + sentinelConnector, + Duration.ofSeconds(30), + (capacity, threadName) -> worker)) { + assertThat(registry.boundRoles()).containsExactly(RedisRole.COORDINATION); + assertThat(runtimeBuilds).hasValue(1); + assertThat(worker.recurringTasks).hasValue(1); + } + assertThat(worker.shutdowns).hasValue(1); + } + + @Test + void sentinelWorkerShutdownBoundIncludesCleanupCompletionMargin() { + RedisClientRuntimeSettings cleanupDominatedSettings = + new RedisClientRuntimeSettings( + "canonical-cleanup", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofSeconds(2), + 8, + 3, + Duration.ofSeconds(5)); + CountingWorker worker = new CountingWorker(); + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + return route("redis-primary.internal"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + return new RouterFakeRuntime(deployment.deploymentId(), discoveredRoute.identity()); + } + }; + + try (RedisCanonicalRoleRegistry ignored = + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, sentinel()), + cleanupDominatedSettings, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + throw new AssertionError("generic connector must not open Sentinel"); + }, + Map.of(), + Map.of(), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + connector, + Duration.ofSeconds(30), + (capacity, threadName) -> worker)) {} + + assertThat(worker.shutdownTimeouts).containsExactly(Duration.ofMillis(2_100)); + } + + @Test + void sessionClusterFailsBeforeRuntimeFactoryBecauseRotationCrossesHashSlots() { + AtomicInteger runtimeBuilds = new AtomicInteger(); + + assertThatThrownBy( + () -> + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.SESSION, cluster()), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + runtimeBuilds.incrementAndGet(); + return new RouterFakeRuntime(deployment.deploymentId()); + })) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("SESSION") + .hasMessageContaining("Cluster") + .hasMessageContaining("hash slot"); + assertThat(runtimeBuilds).hasValue(0); + } + + @Test + void rejectsLegacyPrimaryOrAmbiguousCanonicalAndLegacyActivation() { + assertThatThrownBy(() -> RedisCanonicalActivationValidator.validate(false, false, true, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("migration"); + assertThatThrownBy(() -> RedisCanonicalActivationValidator.validate(true, true, true, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("simultaneous") + .hasMessageContaining("precedence"); + + RedisCanonicalActivationValidator.validate(false, true, true, false); + RedisCanonicalActivationValidator.validate(true, false, false, false); + } + + @Test + void drainTimeoutMustExceedTheRuntimeOverallDeadline() { + assertThatThrownBy( + () -> + new RedisCanonicalRoleRegistry( + Map.of(), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + CLIENT_SETTINGS.overallTimeout().plusMillis(99), + Duration.ofMinutes(5), + deployment -> new RouterFakeRuntime(deployment.deploymentId()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("drain") + .hasMessageContaining("overall") + .hasMessageContaining("100ms"); + } + + @Test + void standaloneAndClusterOnlyRegistryCreatesNoSentinelWorker() { + AtomicInteger workerFactories = new AtomicInteger(); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of( + RedisRole.CACHE, standalone("cache-main"), + RedisRole.COORDINATION, cluster("coord-cluster")), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> new RouterFakeRuntime(deployment.deploymentId()), + Map.of(), + Map.of(), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + null, + Duration.ofSeconds(30), + (capacity, threadName) -> { + workerFactories.incrementAndGet(); + throw new AssertionError("non-Sentinel registry must not create a worker"); + })) { + assertThat(registry.boundRoles()) + .containsExactlyInAnyOrder(RedisRole.CACHE, RedisRole.COORDINATION); + } + assertThat(workerFactories).hasValue(0); + } + + @Test + void hintedMutationQueuesRefreshWithoutReplayAndOnlyNextCommandUsesInstalledRoute() { + RedisCommandFailureException original = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL, + "write unavailable", + null); + RouterFakeRuntime initial = + new RouterFakeRuntime("coord-old", route("redis-primary.internal").identity()); + initial.writeFailure = original; + RouterFakeRuntime candidate = + new RouterFakeRuntime("coord-new", route("redis-replica-a.internal").identity()); + AtomicInteger discoveryCalls = new AtomicInteger(); + AtomicInteger connectCalls = new AtomicInteger(); + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + return discoveryCalls.incrementAndGet() == 1 + ? route("redis-primary.internal") + : route("redis-replica-a.internal"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + return connectCalls.incrementAndGet() == 1 ? initial : candidate; + } + }; + CountingWorker worker = new CountingWorker(); + try (RedisCanonicalRoleRegistry registry = sentinelRegistry(connector, worker)) { + Throwable thrown = + org.assertj.core.api.Assertions.catchThrowable( + () -> + registry + .router(RedisRole.COORDINATION) + .set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1}), + RedisBinaryValue.utf8("value"), + Duration.ofSeconds(5))); + + assertThat(thrown).isSameAs(original); + assertThat(initial.writes).hasValue(1); + assertThat(candidate.writes).hasValue(0); + assertThat(worker.immediateTasks).hasSize(1); + + worker.runNextImmediate(); + assertThat(candidate.writes).hasValue(0); + registry + .router(RedisRole.COORDINATION) + .set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1}), + RedisBinaryValue.utf8("next"), + Duration.ofSeconds(5)); + + assertThat(initial.writes).hasValue(1); + assertThat(candidate.writes).hasValue(1); + assertThat(registry.router(RedisRole.COORDINATION).routeToken().generation()).isEqualTo(1); + } + } + + @Test + void topologyFailureListenerIsNotWiredToStandaloneRoleInMixedRegistry() { + RedisCommandFailureException hinted = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL, + "read unavailable", + null); + RouterFakeRuntime standalone = new RouterFakeRuntime("cache-main"); + standalone.writeFailure = hinted; + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + return route("redis-primary.internal"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + return new RouterFakeRuntime(deployment.deploymentId(), discoveredRoute.identity()); + } + }; + CountingWorker worker = new CountingWorker(); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of( + RedisRole.CACHE, standalone("cache-main"), + RedisRole.COORDINATION, sentinel()), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> standalone, + Map.of(), + Map.of(), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + connector, + Duration.ofSeconds(30), + (capacity, threadName) -> worker)) { + Throwable thrown = + org.assertj.core.api.Assertions.catchThrowable( + () -> + registry + .router(RedisRole.CACHE) + .set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1}), + RedisBinaryValue.utf8("value"), + Duration.ofSeconds(5))); + + assertThat(thrown).isSameAs(hinted); + assertThat(worker.immediateTasks).isEmpty(); + } + } + + @Test + void successfulSentinelInstallMarksDormantOptionalCacheRecoveryActive() { + AtomicInteger dataConnects = new AtomicInteger(); + AtomicInteger legacyRecoveryConnects = new AtomicInteger(); + java.util.concurrent.atomic.AtomicLong ticker = new java.util.concurrent.atomic.AtomicLong(); + RouterFakeRuntime candidate = + new RouterFakeRuntime("cache-active", route("redis-replica-a.internal").identity()); + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + return dataConnects.get() == 0 + ? route("redis-primary.internal") + : route("redis-replica-a.internal"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + if (dataConnects.incrementAndGet() == 1) { + throw new RedisTemporaryConnectionException(); + } + return candidate; + } + }; + CountingWorker worker = new CountingWorker(); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.CACHE, sentinel("cache-main")), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + legacyRecoveryConnects.incrementAndGet(); + throw new RedisTemporaryConnectionException(); + }, + Map.of( + RedisRole.CACHE, + new dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding( + "cache-main", false, "allkeys-lfu")), + Map.of( + RedisRole.CACHE, + Set.of(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.CACHE)), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + ticker::get, + NoOpRedisCapabilityObservationPort.instance(), + connector, + Duration.ofSeconds(30), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + })) { + ticker.addAndGet(Duration.ofSeconds(6).toNanos()); + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.RoleHealth dormantHealth = + registry.snapshot().roles().getFirst(); + assertThat(dormantHealth.state()) + .isEqualTo(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State.UNAVAILABLE); + assertThat(legacyRecoveryConnects).hasValue(0); + + worker.runRecurring(0); + worker.runNextImmediate(); + + dev.caskeleton.shared.health.RedisHealthSnapshotProvider.RoleHealth health = + registry.snapshot().roles().getFirst(); + assertThat(health.state()) + .isEqualTo(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State.AVAILABLE); + assertThat(legacyRecoveryConnects).hasValue(0); + assertThat(registry.router(RedisRole.CACHE).routeToken().generation()).isEqualTo(1); + } + } + + @Test + void semanticQualificationFailureClosesSentinelCandidateExactlyOnce() { + RouterFakeRuntime initial = + new RouterFakeRuntime("cache-old", route("redis-primary.internal").identity()); + RouterFakeRuntime rejected = + new RouterFakeRuntime("cache-rejected", route("redis-replica-a.internal").identity()); + rejected.probeFailure = new RedisTemporaryConnectionException(); + AtomicInteger discoveries = new AtomicInteger(); + AtomicInteger connects = new AtomicInteger(); + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + return discoveries.incrementAndGet() == 1 + ? route("redis-primary.internal") + : route("redis-replica-a.internal"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + return connects.incrementAndGet() == 1 ? initial : rejected; + } + }; + CountingWorker worker = new CountingWorker(); + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + Map.of(RedisRole.CACHE, sentinel("cache-main")), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + throw new AssertionError("generic connector must not open Sentinel"); + }, + Map.of( + RedisRole.CACHE, + new dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding( + "cache-main", true, "allkeys-lfu")), + Map.of( + RedisRole.CACHE, + Set.of(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability.CACHE)), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + connector, + Duration.ofSeconds(30), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + })) { + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(rejected.closes).hasValue(1); + assertThat(initial.closes).hasValue(0); + assertThat(registry.router(RedisRole.CACHE).routeToken().generation()).isZero(); + } + } + + private static RedisCanonicalRoleRegistry sentinelRegistry( + RedisSentinelRuntimeConnector connector, CountingWorker worker) { + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, sentinel()), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + throw new AssertionError("generic connector must not open Sentinel"); + }, + Map.of(), + Map.of(), + java.time.Clock.systemUTC(), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + System::nanoTime, + NoOpRedisCapabilityObservationPort.instance(), + connector, + Duration.ofSeconds(30), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + }); + } + + private static RedisDeploymentSettings.Standalone standalone(String id) { + return new RedisDeploymentSettings.Standalone( + id, + 0, + List.of(new RedisDeploymentSettings.Endpoint(id + ".internal", 6379)), + new RedisDeploymentSettings.Authentication( + "runtime", "secret://environment/REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/REDIS_TRUST_PEM")); + } + + private static RedisDeploymentSettings.Sentinel sentinel() { + return sentinel("sentinel-main"); + } + + private static RedisDeploymentSettings.Sentinel sentinel(String id) { + return new RedisDeploymentSettings.Sentinel( + id, + 0, + "master", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "sentinel", "secret://environment/SENTINEL_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/SENTINEL_TRUST_PEM"), + new RedisDeploymentSettings.Authentication("data", "secret://environment/REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/REDIS_TRUST_PEM")); + } + + private static RedisDeploymentSettings.Cluster cluster() { + return cluster("session-cluster"); + } + + private static RedisDeploymentSettings.Cluster cluster(String id) { + return new RedisDeploymentSettings.Cluster( + id, + 0, + List.of(new RedisDeploymentSettings.Endpoint("redis-cluster.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "session", "secret://environment/REDIS_SESSION_PASSWORD"), + new RedisDeploymentSettings.Tls( + true, true, "secret://environment/REDIS_SESSION_TRUST_PEM")); + } + + private static RedisSentinelDiscoveredRoute route(String host) { + return RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint(host, 6379)); + } + + private static final class RouterFakeRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final RedisRouteIdentity identity; + private final AtomicInteger writes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + private final java.util.Map values = new java.util.HashMap<>(); + private RedisCommandFailureException writeFailure; + private RuntimeException probeFailure; + + private RouterFakeRuntime(String deploymentId) { + this(deploymentId, null); + } + + private RouterFakeRuntime(String deploymentId, RedisRouteIdentity identity) { + this.deploymentId = deploymentId; + this.identity = identity; + } + + @Override + public RedisRouteIdentity routeIdentity() { + return identity == null ? RedisRoutableCommandRuntime.super.routeIdentity() : identity; + } + + @Override + public void probe(Duration timeout) { + if (probeFailure != null) { + throw probeFailure; + } + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + byte[] value = values.get(encoded(key)); + return value == null + ? deploymentId.getBytes(java.nio.charset.StandardCharsets.UTF_8) + : value.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + writes.incrementAndGet(); + if (writeFailure != null) { + throw writeFailure; + } + values.put(encoded(key), value.copyEncoded()); + } + + @Override + public long delete(RedisPhysicalKey key) { + return values.remove(encoded(key)) == null ? 0 : 1; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return switch (invocation.replyShape()) { + case READ_ONLY_VALUE -> + RedisCatalogProgramReply.value( + "ACL_OK".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + case VALUE -> + RedisCatalogProgramReply.value( + "EXISTS".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + case MULTI, READ_ONLY_MULTI -> + RedisCatalogProgramReply.multi( + List.of("STATE_INCOMPATIBLE".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + }; + } + + private static String encoded(RedisPhysicalKey key) { + return java.util.Base64.getEncoder().encodeToString(RedisPhysicalKey.WireCodec.copy(key)); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + private static final class CountingWorker implements RedisSentinelRefreshWorker { + + private final AtomicInteger recurringTasks = new AtomicInteger(); + private final AtomicInteger shutdowns = new AtomicInteger(); + private final java.util.List shutdownTimeouts = new java.util.ArrayList<>(); + private final java.util.Queue immediateTasks = new java.util.ArrayDeque<>(); + private final java.util.List scheduledTasks = new java.util.ArrayList<>(); + private int capacity = RedisRole.values().length; + + @Override + public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) { + recurringTasks.incrementAndGet(); + scheduledTasks.add(task); + return () -> { + recurringTasks.decrementAndGet(); + scheduledTasks.remove(task); + }; + } + + @Override + public boolean execute(Runnable task) { + if (immediateTasks.size() >= capacity) { + return false; + } + immediateTasks.add(task); + return true; + } + + @Override + public void shutdown(Duration timeout) { + shutdowns.incrementAndGet(); + shutdownTimeouts.add(timeout); + immediateTasks.clear(); + } + + private void runNextImmediate() { + immediateTasks.remove().run(); + } + + private void runRecurring(int index) { + scheduledTasks.get(index).run(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationContractTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationContractTest.java new file mode 100644 index 0000000..c26f513 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCapabilityObservationContractTest.java @@ -0,0 +1,320 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisCapabilityObservationContractTest { + + @Test + void eventSurfaceIsPackagePrivateClosedImmutableAndCarriesNoIdentityOrWireMaterial() { + assertThat(Modifier.isPublic(RedisCapabilityObservationEvent.class.getModifiers())).isFalse(); + assertThat(RedisCapabilityObservationEvent.Event.class.isSealed()).isTrue(); + assertThat(Modifier.isPublic(RedisCapabilityObservationEvent.Event.class.getModifiers())) + .isFalse(); + assertThat(RedisCapabilityObservationEvent.Event.class.getPermittedSubclasses()) + .containsExactlyInAnyOrder( + RedisCapabilityObservationEvent.OperationCompleted.class, + RedisCapabilityObservationEvent.AdmissionChanged.class, + RedisCapabilityObservationEvent.ReadinessObserved.class, + RedisCapabilityObservationEvent.LifecycleDrainCompleted.class); + + List forbiddenNames = + List.of( + "key", + "subject", + "session", + "token", + "secret", + "endpoint", + "exception", + "script", + "sha", + "cursor", + "coordinate", + "value"); + for (Class eventType : + RedisCapabilityObservationEvent.Event.class.getPermittedSubclasses()) { + assertThat(eventType.isRecord()).isTrue(); + Arrays.stream(eventType.getRecordComponents()) + .forEach( + component -> { + assertThat(component.getType()) + .isNotIn( + String.class, + byte[].class, + Throwable.class, + java.util.Collection.class, + java.util.Map.class); + assertThat(forbiddenNames) + .noneMatch(component.getName().toLowerCase(java.util.Locale.ROOT)::contains); + }); + } + } + + @Test + void enumAndNumericBoundsAreClosedAndExact() { + assertThat(RedisCapabilityObservationEvent.Capability.values()) + .extracting(Enum::name) + .containsExactly( + "CACHE", "RATE_LIMIT", "IDEMPOTENCY", "EFFICIENCY_LEASE", "SESSION", "RUNTIME"); + assertThat(RedisCapabilityObservationEvent.Role.values()) + .extracting(Enum::name) + .containsExactly("CACHE", "COORDINATION", "SESSION"); + assertThat(RedisCapabilityObservationEvent.Certainty.values()) + .extracting(Enum::name) + .containsExactly("DEFINITE", "NOT_APPLIED", "INDETERMINATE"); + assertThat(RedisCapabilityObservationEvent.AdmissionState.values()) + .extracting(Enum::name) + .containsExactly("ADMITTED", "REJECTED_SATURATED", "REJECTED_CLOSED", "NOT_APPLICABLE"); + assertThat(RedisCapabilityObservationEvent.DrainOutcome.values()) + .extracting(Enum::name) + .containsExactly("DRAINED", "FORCED_AFTER_TIMEOUT", "INTERRUPTED"); + assertThat(RedisCapabilityObservationEvent.Operation.values()) + .extracting(Enum::name) + .containsExactly( + "LOOKUP", + "RECORD", + "INVALIDATE", + "REFRESH_CLAIM", + "REFRESH_RELEASE", + "RATE_EVALUATE", + "IDEMPOTENCY_CLAIM", + "IDEMPOTENCY_START", + "IDEMPOTENCY_RENEW", + "IDEMPOTENCY_COMPLETE", + "IDEMPOTENCY_FAIL", + "IDEMPOTENCY_RELEASE", + "IDEMPOTENCY_INSPECT", + "LEASE_ACQUIRE", + "LEASE_INSPECT", + "LEASE_RENEW", + "LEASE_RELEASE", + "SESSION_CREATE", + "SESSION_INSPECT", + "SESSION_SAVE", + "SESSION_TOUCH", + "SESSION_REVOKE", + "SESSION_ROTATE", + "ROUTE_COMMAND"); + + assertThatCode( + () -> + new RedisCapabilityObservationEvent.OperationCompleted( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE, + RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS)) + .doesNotThrowAnyException(); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + new RedisCapabilityObservationEvent.OperationCompleted( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE, + RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS + 1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void safeObserverCannotChangeResultOrPropagateObservationFailure() { + AtomicInteger attempts = new AtomicInteger(); + RedisCapabilityObservationPort safe = + new SafeRedisCapabilityObservationPort( + ignored -> { + attempts.incrementAndGet(); + throw new IllegalStateException("registry failed with sensitive detail"); + }); + var event = + new RedisCapabilityObservationEvent.OperationCompleted( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE, + 1); + + assertThatCode(() -> safe.observe(event)).doesNotThrowAnyException(); + assertThat(attempts).hasValue(1); + assertThat(NoOpRedisCapabilityObservationPort.instance()) + .isSameAs(NoOpRedisCapabilityObservationPort.instance()); + } + + @Test + void semanticObserverPreservesTheExactResultAndFailureInstance() { + AtomicLong ticker = new AtomicLong(); + RedisCapabilityObserver observer = + new RedisCapabilityObserver( + ignored -> { + throw new IllegalStateException("meter registry unavailable"); + }, + () -> ticker.getAndAdd(10)); + Object expected = new Object(); + + Object actual = + observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + () -> expected, + ignored -> + new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE)); + + assertThat(actual).isSameAs(expected); + + IllegalArgumentException expectedFailure = new IllegalArgumentException("command failed"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.RECORD, + () -> { + throw expectedFailure; + }, + ignored -> + new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE))) + .isSameAs(expectedFailure); + } + + @Test + void classifierFailureCannotReplaceAResultOrInvokeTheActionTwice() { + AtomicInteger actions = new AtomicInteger(); + Object expected = new Object(); + RedisCapabilityObserver observer = + new RedisCapabilityObserver( + NoOpRedisCapabilityObservationPort.instance(), System::nanoTime); + + Object actual = + observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + () -> { + actions.incrementAndGet(); + return expected; + }, + ignored -> { + throw new IllegalStateException("diagnostic classifier failed"); + }); + + assertThat(actual).isSameAs(expected); + assertThat(actions).hasValue(1); + } + + @Test + void tickerFailureCannotPreventOrReplaceAResult() { + AtomicInteger actions = new AtomicInteger(); + AtomicInteger ticks = new AtomicInteger(); + Object expected = new Object(); + RedisCapabilityObserver observer = + new RedisCapabilityObserver( + NoOpRedisCapabilityObservationPort.instance(), + () -> { + int attempt = ticks.incrementAndGet(); + throw new IllegalStateException("ticker failed at sample " + attempt); + }); + + Object actual = + observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.LOOKUP, + () -> { + actions.incrementAndGet(); + return expected; + }, + ignored -> + new RedisCapabilityObserver.Classification( + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE)); + + assertThat(actual).isSameAs(expected); + assertThat(actions).hasValue(1); + assertThat(ticks).hasValue(2); + } + + @Test + void everyDiagnosticFailureStillRethrowsTheExactCommandFailureOnce() { + AtomicInteger actions = new AtomicInteger(); + AtomicInteger classifiers = new AtomicInteger(); + IllegalArgumentException expected = new IllegalArgumentException("authoritative failure"); + RedisCapabilityObserver observer = + new RedisCapabilityObserver( + ignored -> { + throw new IllegalStateException("port failed"); + }, + () -> { + throw new IllegalStateException("ticker failed"); + }); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + observer.observe( + RedisCapabilityObservationEvent.Capability.CACHE, + RedisCapabilityObservationEvent.Role.CACHE, + RedisCapabilityObservationEvent.Operation.RECORD, + () -> { + actions.incrementAndGet(); + throw expected; + }, + ignored -> { + classifiers.incrementAndGet(); + throw new IllegalStateException("classifier failed"); + })) + .isSameAs(expected); + assertThat(actions).hasValue(1); + assertThat(classifiers).hasValue(0); + } + + @Test + void recordingTestPortPreservesAllEventsFromConcurrentWorkers() throws Exception { + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + int workers = 32; + int eventsPerWorker = 512; + java.util.concurrent.CountDownLatch ready = new java.util.concurrent.CountDownLatch(workers); + java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + var event = + new RedisCapabilityObservationEvent.LifecycleDrainCompleted( + RedisCapabilityObservationEvent.Role.SESSION, + RedisCapabilityObservationEvent.DrainOutcome.DRAINED); + + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var futures = + java.util.stream.IntStream.range(0, workers) + .mapToObj( + ignored -> + executor.submit( + () -> { + ready.countDown(); + start.await(); + for (int index = 0; index < eventsPerWorker; index++) { + observations.observe(event); + } + return null; + })) + .toList(); + assertThat(ready.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + start.countDown(); + for (java.util.concurrent.Future future : futures) { + future.get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + + assertThat(observations.events()).hasSize(workers * eventsPerWorker); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfileTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfileTest.java new file mode 100644 index 0000000..c5a9e95 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisConnectionProfileTest.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisConnectionProfileTest { + + @Test + void legacyCoordinationProfileReservesRequestOverheadInsideThePerCommandByteBudget() { + RedisLegacyStandaloneSettings settings = + new RedisLegacyStandaloneSettings( + "redis.internal", + 6379, + "", + "cHJvZHVjdGlvbi1zYWZlLWhhcmRlbmVkLXRlc3QtaG1hYy1tYXRlcmlhbA==", + Duration.ofSeconds(1), + 16_384, + 8, + 1_048_576, + "ca-skeleton", + "test"); + + RedisConnectionProfile profile = RedisConnectionProfile.rateLimit(settings); + + assertThat(profile.maximumReadableValueBytes()).isEqualTo(12_288); + assertThat(profile.maximumReadableValueBytes()).isLessThan(profile.maximumCommandBytes()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactoryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactoryTest.java new file mode 100644 index 0000000..5bbe489 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisDeploymentRuntimeFactoryTest.java @@ -0,0 +1,304 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSslOptionsFactory; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisURI; +import io.lettuce.core.cluster.ClusterClientOptions; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RedisDeploymentRuntimeFactoryTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "worklog-cache", + Duration.ofMillis(400), + Duration.ofMillis(600), + Duration.ofMillis(250), + Duration.ofMillis(700), + Duration.ofSeconds(2), + Duration.ofSeconds(3), + 17, + 5, + Duration.ofSeconds(11)); + + @Test + void opensStandaloneAndClusterNativeConnectionsWithExplicitSslOptions() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + CapturingNativeClientFactory nativeClients = new CapturingNativeClientFactory(); + RedisDeploymentRuntimeFactory factory = runtimeFactory(credentials, trust, nativeClients); + + RedisDeploymentRuntime standalone = factory.create(standalone(), CLIENT_SETTINGS); + RedisDeploymentRuntime cluster = factory.create(cluster(), CLIENT_SETTINGS); + try { + assertThat(standalone.topology()).isEqualTo(RedisDeploymentRuntime.Topology.STANDALONE); + assertThat(cluster.topology()).isEqualTo(RedisDeploymentRuntime.Topology.CLUSTER); + assertThat(nativeClients.openedKinds) + .containsExactly( + RedisDeploymentRuntime.Topology.STANDALONE, RedisDeploymentRuntime.Topology.CLUSTER); + assertThat(nativeClients.standaloneUri.getHost()).isEqualTo("standalone.internal"); + assertThat(nativeClients.clusterUris) + .extracting(RedisURI::getHost) + .containsExactly("cluster-a.internal", "cluster-b.internal", "cluster-c.internal"); + assertThat(nativeClients.clientOptions.getSslOptions().getHandshakeTimeout()) + .isEqualTo(Duration.ofMillis(600)); + assertThat(nativeClients.clusterOptions.getSslOptions().getHandshakeTimeout()) + .isEqualTo(Duration.ofMillis(600)); + assertThat(credentials.materials) + .allSatisfy(material -> assertThat(material.isDestroyed()).isTrue()); + assertThat(trust.materials) + .allSatisfy(material -> assertThat(material.isDestroyed()).isTrue()); + } finally { + standalone.close(); + cluster.close(); + } + } + + @Test + void sentinelIsFailClosedBecauseLettuceHasOneSslContextForDiscoveryAndData() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + CapturingNativeClientFactory nativeClients = new CapturingNativeClientFactory(); + + assertThatThrownBy( + () -> + runtimeFactory(credentials, trust, nativeClients) + .create(sentinel(), CLIENT_SETTINGS)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Sentinel") + .hasMessageContaining("separate") + .hasMessageContaining("unsupported") + .hasMessageNotContaining("secret://"); + + assertThat(credentials.resolveCount).isZero(); + assertThat(trust.resolveCount).isZero(); + assertThat(nativeClients.openedKinds).isEmpty(); + } + + @Test + void deploymentDefinitionWithoutRoleBindingCreatesNoCredentialsClientOrRuntimeResources() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + CapturingNativeClientFactory nativeClients = new CapturingNativeClientFactory(); + RedisDeploymentRuntimeFactory factory = runtimeFactory(credentials, trust, nativeClients); + + assertThat( + factory.createIfBound( + RedisRole.CACHE, Map.of(RedisRole.SESSION, standalone()), CLIENT_SETTINGS)) + .isEmpty(); + + assertThat(credentials.resolveCount).isZero(); + assertThat(trust.resolveCount).isZero(); + assertThat(nativeClients.openedKinds).isEmpty(); + assertThat(nativeClients.handles).isEmpty(); + } + + @Test + void usesSeparateBoundedTimeoutsAndPreservesNoReplayRejectAndFiniteQueueOptions() { + CapturingNativeClientFactory nativeClients = new CapturingNativeClientFactory(); + RedisDeploymentRuntime runtime = + runtimeFactory( + new CapturingCredentialProvider(), new CapturingTrustProvider(), nativeClients) + .create(standalone(), CLIENT_SETTINGS); + + assertThat(runtime.timeouts().connect()).isEqualTo(Duration.ofMillis(400)); + assertThat(runtime.timeouts().acquire()).isEqualTo(Duration.ofMillis(250)); + assertThat(runtime.timeouts().command()).isEqualTo(Duration.ofMillis(700)); + assertThat(runtime.timeouts().overall()).isEqualTo(Duration.ofSeconds(2)); + assertThat(runtime.timeouts().shutdown()).isEqualTo(Duration.ofSeconds(3)); + assertThat(nativeClients.openSettings).isSameAs(CLIENT_SETTINGS); + assertThat(nativeClients.clientOptions.getSocketOptions().getConnectTimeout()) + .isEqualTo(Duration.ofMillis(400)); + assertThat(nativeClients.clientOptions.getReplayFilter().test(null)).isTrue(); + assertThat(nativeClients.clientOptions.getDisconnectedBehavior()) + .isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS); + assertThat(nativeClients.clientOptions.getRequestQueueSize()).isEqualTo(17); + + runtime.close(); + + assertThat(nativeClients.handles.getFirst().shutdownTimeout).isEqualTo(Duration.ofSeconds(3)); + } + + private static RedisDeploymentRuntimeFactory runtimeFactory( + RedisCredentialMaterialProvider credentials, + RedisTrustMaterialProvider trust, + RedisNativeClientFactory nativeClients) { + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + return new RedisDeploymentRuntimeFactory( + new RedisLettuceUriFactory(credentials, clock), + new RedisSslOptionsFactory(trust, clock), + new RedisLettuceClientOptionsFactory(), + nativeClients); + } + + private static RedisDeploymentSettings.Standalone standalone() { + return new RedisDeploymentSettings.Standalone( + "standalone-main", + 2, + List.of(new RedisDeploymentSettings.Endpoint("standalone.internal", 6380)), + dataAuthentication(), + tls("secret://redis/data/ca")); + } + + private static RedisDeploymentSettings.Sentinel sentinel() { + return new RedisDeploymentSettings.Sentinel( + "sentinel-main", + 4, + "coordination-master", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca"), + dataAuthentication(), + tls("secret://redis/data/ca")); + } + + private static RedisDeploymentSettings.Cluster cluster() { + return new RedisDeploymentSettings.Cluster( + "cluster-main", + 0, + List.of( + new RedisDeploymentSettings.Endpoint("cluster-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("cluster-b.internal", 6379), + new RedisDeploymentSettings.Endpoint("cluster-c.internal", 6379)), + dataAuthentication(), + tls("secret://redis/data/ca")); + } + + private static RedisDeploymentSettings.Authentication dataAuthentication() { + return new RedisDeploymentSettings.Authentication( + "data-runtime", "secret://redis/data/password"); + } + + private static RedisDeploymentSettings.Tls tls(String reference) { + return new RedisDeploymentSettings.Tls(true, true, reference); + } + + private static byte[] validPem() { + try { + return RedisDeploymentRuntimeFactoryTest.class + .getResourceAsStream("/redis-test-ca.pem") + .readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Redis test CA could not be read", exception); + } + } + + private static final class CapturingCredentialProvider + implements RedisCredentialMaterialProvider { + + private final List materials = new ArrayList<>(); + private int resolveCount; + + @Override + public VersionedRedisCredentialMaterial resolve(RedisSecretReference reference) { + resolveCount++; + VersionedRedisCredentialMaterial material = + new VersionedRedisCredentialMaterial( + "credential-v1", + NOW.plusSeconds(3600), + DestroyableRedisSecret.from("data-password".toCharArray())); + materials.add(material); + return material; + } + } + + private static final class CapturingTrustProvider implements RedisTrustMaterialProvider { + + private final List materials = new ArrayList<>(); + private int resolveCount; + + @Override + public VersionedRedisTrustMaterial resolve(RedisSecretReference reference) { + resolveCount++; + VersionedRedisTrustMaterial material = + new VersionedRedisTrustMaterial( + "trust-v1", NOW.plusSeconds(3600), DestroyableRedisPem.from(validPem())); + materials.add(material); + return material; + } + } + + private static final class CapturingNativeClientFactory implements RedisNativeClientFactory { + + private final List openedKinds = new ArrayList<>(); + private final List handles = new ArrayList<>(); + private RedisURI standaloneUri; + private List clusterUris; + private ClientOptions clientOptions; + private ClusterClientOptions clusterOptions; + private RedisClientRuntimeSettings openSettings; + + @Override + public RedisNativeClientHandle openStandalone( + RedisURI uri, ClientOptions options, RedisClientRuntimeSettings settings) { + openedKinds.add(RedisDeploymentRuntime.Topology.STANDALONE); + standaloneUri = uri; + clientOptions = options; + openSettings = settings; + return handle(); + } + + @Override + public RedisNativeClientHandle openCluster( + List seedUris, + ClusterClientOptions options, + RedisClientRuntimeSettings settings) { + openedKinds.add(RedisDeploymentRuntime.Topology.CLUSTER); + clusterUris = List.copyOf(seedUris); + clusterOptions = options; + openSettings = settings; + return handle(); + } + + private CapturingHandle handle() { + CapturingHandle handle = new CapturingHandle(); + handles.add(handle); + return handle; + } + } + + private static final class CapturingHandle implements RedisNativeClientHandle { + + private Duration shutdownTimeout; + + @Override + public Class nativeClientType() { + return Object.class; + } + + @Override + public void close(Duration timeout) { + shutdownTimeout = timeout; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProviderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProviderTest.java new file mode 100644 index 0000000..1598954 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEdgeRateLimitProviderTest.java @@ -0,0 +1,539 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisEdgeRateLimitProviderTest { + + private static final Instant NOW = Instant.parse("2026-07-28T12:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final String SUBJECT_DIGEST = + "hv1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final byte[] HMAC_SECRET = + "rate-limit-test-hmac-secret-at-least-32-bytes".getBytes(US_ASCII); + private static final String EVALUATION_ID = "ev1:" + "A".repeat(22); + + @Test + void emitsActualDeniedAndIndeterminateRateOutcomesOncePerEvaluation() { + CapturingExecutor executor = new CapturingExecutor(); + executor.reply = + new RedisRateProgramReply( + RedisRateProgramStatus.DENIED, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 100, + 0, + 250, + NOW.plusMillis(250).toEpochMilli()); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + AtomicLong ticker = new AtomicLong(); + RedisEdgeRateLimitProvider provider = + new RedisEdgeRateLimitProvider( + Map.of("login", fixedPolicy("login", "r1")), + RedisProgramCatalog.rateLimit(), + executor, + "worklog-api", + "test", + 1, + 1, + HMAC_SECRET, + CLOCK, + Duration.ofMillis(100), + Duration.ZERO, + observations, + () -> ticker.getAndAdd(10)); + + assertThat(provider.evaluate(request("login", 1))) + .isInstanceOf(RateLimitOutcome.Evaluated.class); + executor.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "response lost", + null); + assertThat(provider.evaluate(request("login", 1))) + .isInstanceOf(RateLimitOutcome.Indeterminate.class); + + assertThat(observations.operations()) + .extracting( + RedisCapabilityObservationEvent.OperationCompleted::outcome, + RedisCapabilityObservationEvent.OperationCompleted::certainty) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Outcome.DENIED, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE)); + } + + @Test + void selectsTheExactProgramAndMapsAlgorithmCertainty() { + CapturingExecutor executor = new CapturingExecutor(); + Map policies = new LinkedHashMap<>(); + policies.put("fixed", fixedPolicy("fixed", "r1")); + policies.put("sliding", slidingPolicy("sliding", "r1")); + policies.put("tokens", tokenPolicy("tokens", "r1")); + RedisEdgeRateLimitProvider provider = provider(policies, executor); + + assertDecision(provider.evaluate(request("fixed", 1, EVALUATION_ID)), true, 99, true); + assertThat(executor.descriptor.id()).isEqualTo(RedisProgramId.RATE_FIXED_WINDOW_V2); + assertThat(ascii(executor.arguments)) + .containsExactly( + "2", "r1", "100", "1", "1000", "5000", "250", EVALUATION_ID, "5000", "256", "65536"); + assertThat(executor.keys).hasSize(3); + assertThat(ascii(executor.keys)) + .allSatisfy(key -> assertThat(key).contains("{").contains("}")) + .extracting(key -> key.substring(key.indexOf('{'), key.indexOf('}') + 1)) + .containsOnly(ascii(executor.keys).getFirst().replaceAll(".*(\\{[^}]+}).*", "$1")); + + assertDecision(provider.evaluate(request("sliding", 1)), true, 99, false); + assertThat(executor.descriptor.id()).isEqualTo(RedisProgramId.RATE_SLIDING_COUNTER_V2); + + executor.reply = + new RedisRateProgramReply( + RedisRateProgramStatus.ALLOWED, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 10, + 9, + 0, + NOW.plusSeconds(1).toEpochMilli()); + assertDecision(provider.evaluate(request("tokens", 1)), true, 9, true); + assertThat(executor.descriptor.id()).isEqualTo(RedisProgramId.RATE_TOKEN_BUCKET_V2); + assertThat(ascii(executor.arguments)) + .containsExactly( + "2", + "r1", + "10000000", + "10000000", + "1000", + "1000000", + "5000", + "250", + "-", + "5000", + "256", + "65536"); + } + + @Test + void physicalKeyHidesTheSubjectAndChangesWithThePolicyRevision() { + CapturingExecutor firstExecutor = new CapturingExecutor(); + RedisEdgeRateLimitProvider first = + provider(Map.of("login", fixedPolicy("login", "r1")), firstExecutor); + first.evaluate(request("login", 1)); + String firstKey = new String(firstExecutor.keys.getFirst(), US_ASCII); + + CapturingExecutor secondExecutor = new CapturingExecutor(); + RedisEdgeRateLimitProvider second = + provider(Map.of("login", fixedPolicy("login", "r2")), secondExecutor); + second.evaluate(request("login", 1)); + String secondKey = new String(secondExecutor.keys.getFirst(), US_ASCII); + + assertThat(firstKey) + .startsWith("ca:worklog-api:test:rate:login:hv1:kv1:{") + .endsWith(":state") + .doesNotContain(SUBJECT_DIGEST) + .doesNotContain("0123456789abcdef"); + assertThat(secondKey).isNotEqualTo(firstKey); + } + + @Test + void denyCarriesTheLuaRetryAndResetWithoutChangingItsCertainty() { + CapturingExecutor executor = new CapturingExecutor(); + executor.reply = + new RedisRateProgramReply( + RedisRateProgramStatus.DENIED, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 100, + 0, + 250, + NOW.plusMillis(250).toEpochMilli()); + RedisEdgeRateLimitProvider provider = + provider(Map.of("login", fixedPolicy("login", "r1")), executor); + + RateLimitOutcome.Evaluated evaluated = + (RateLimitOutcome.Evaluated) provider.evaluate(request("login", 1)); + + assertThat(evaluated.decision().allowed()).isFalse(); + assertThat(evaluated.decision().retryAfter()).isEqualTo(Duration.ofMillis(250)); + assertThat(evaluated.decision().resetAt()).isEqualTo(NOW.plusMillis(250)); + } + + @Test + void mapsClockStateReplyAndTransportCertaintyWithoutFailOpen() { + CapturingExecutor executor = new CapturingExecutor(); + RedisEdgeRateLimitProvider provider = + provider(Map.of("login", fixedPolicy("login", "r1")), executor); + + executor.reply = + new RedisRateProgramReply( + RedisRateProgramStatus.CLOCK_UNSAFE, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 100, + 50, + 0, + 0); + assertThat(provider.evaluate(request("login", 1))) + .isEqualTo( + new RateLimitOutcome.Unavailable( + "login", + Duration.ofMillis(100), + RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE)); + + executor.reply = + new RedisRateProgramReply( + RedisRateProgramStatus.STATE_INCOMPATIBLE, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 100, + 0, + 0, + 0); + assertThat(provider.evaluate(request("login", 1))) + .isEqualTo( + new RateLimitOutcome.Incompatible( + "login", RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE)); + + executor.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.OVERLOADED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "saturated", + null); + assertThat(provider.evaluate(request("login", 1))) + .isEqualTo( + new RateLimitOutcome.Unavailable( + "login", + Duration.ofMillis(100), + RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED)); + + executor.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "timeout", + null); + assertThat(provider.evaluate(request("login", 1))) + .isEqualTo(new RateLimitOutcome.Indeterminate("login", Duration.ofMillis(100))); + } + + @Test + void retriesOneIndeterminateSendWithTheExactSameEvaluationInvocation() { + CapturingExecutor executor = new CapturingExecutor(); + executor.indeterminateFailures = 1; + RedisEdgeRateLimitProvider provider = + providerWithCommandBudget( + Map.of("login", fixedPolicy("login", "r1")), executor, Duration.ofSeconds(1)); + + assertDecision(provider.evaluate(request("login", 1, EVALUATION_ID)), true, 99, true); + + assertThat(executor.calls).isEqualTo(2); + assertThat(executor.invocationKeys).hasSize(2); + assertThat(executor.invocationArguments).hasSize(2); + assertThat(ascii(executor.invocationKeys.get(1))) + .containsExactlyElementsOf(ascii(executor.invocationKeys.getFirst())); + assertThat(ascii(executor.invocationArguments.get(1))) + .containsExactlyElementsOf(ascii(executor.invocationArguments.getFirst())); + } + + @Test + void boundsTheRecoveryRetryAndNeverRetriesWhenDedupIsDisabled() { + CapturingExecutor twiceIndeterminate = new CapturingExecutor(); + twiceIndeterminate.indeterminateFailures = 2; + RedisEdgeRateLimitProvider enabled = + providerWithCommandBudget( + Map.of("enabled", fixedPolicy("enabled", "r1")), + twiceIndeterminate, + Duration.ofSeconds(1)); + + assertThat(enabled.evaluate(request("enabled", 1, EVALUATION_ID))) + .isEqualTo(new RateLimitOutcome.Indeterminate("enabled", Duration.ofMillis(100))); + assertThat(twiceIndeterminate.calls).isEqualTo(2); + + CapturingExecutor disabledExecutor = new CapturingExecutor(); + disabledExecutor.indeterminateFailures = 1; + RateLimitPolicy disabledPolicy = + new RateLimitPolicy( + "disabled", + "r1", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(100, Duration.ofSeconds(1)), + 10, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED, + RateLimitEvaluationDedupPolicy.disabled()); + RedisEdgeRateLimitProvider disabled = + providerWithCommandBudget( + Map.of("disabled", disabledPolicy), disabledExecutor, Duration.ofSeconds(1)); + + assertThat(disabled.evaluate(request("disabled", 1, EVALUATION_ID))) + .isEqualTo(new RateLimitOutcome.Indeterminate("disabled", Duration.ofMillis(100))); + assertThat(disabledExecutor.calls).isEqualTo(1); + } + + @Test + void rejectsUnknownPolicyExcessCostAndExpiredDeadlineBeforeRedis() { + CapturingExecutor executor = new CapturingExecutor(); + RedisEdgeRateLimitProvider provider = + provider(Map.of("login", fixedPolicy("login", "r1")), executor); + + assertThat(provider.evaluate(request("unknown", 1))) + .isEqualTo( + new RateLimitOutcome.Incompatible( + "unknown", RateLimitOutcome.IncompatibleCategory.STATE_INCOMPATIBLE)); + assertThatThrownBy(() -> provider.evaluate(request("login", 11))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumCost"); + RateLimitRequest expired = + new RateLimitRequest("login", SUBJECT_DIGEST, 1, "", NOW.plusSeconds(1)); + RedisEdgeRateLimitProvider futureClockProvider = + new RedisEdgeRateLimitProvider( + Map.of("login", fixedPolicy("login", "r1")), + RedisProgramCatalog.rateLimit(), + executor, + "worklog-api", + "test", + 1, + 1, + HMAC_SECRET, + Clock.fixed(NOW.plusSeconds(2), ZoneOffset.UTC), + Duration.ofMillis(100)); + + assertThat(futureClockProvider.evaluate(expired)) + .isEqualTo( + new RateLimitOutcome.Unavailable( + "login", + Duration.ofMillis(100), + RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED)); + assertThat(executor.calls).isZero(); + } + + @Test + void rejectsBeforeDispatchWhenTheCallerBudgetCannotCoverTheCommandTimeout() { + CapturingExecutor executor = new CapturingExecutor(); + RedisEdgeRateLimitProvider provider = + new RedisEdgeRateLimitProvider( + Map.of("login", fixedPolicy("login", "r1")), + RedisProgramCatalog.rateLimit(), + executor, + "worklog-api", + "test", + 1, + 1, + HMAC_SECRET, + CLOCK, + Duration.ofMillis(100), + Duration.ofSeconds(1)); + RateLimitRequest request = + new RateLimitRequest("login", SUBJECT_DIGEST, 1, "", NOW.plusMillis(999)); + + assertThat(provider.evaluate(request)) + .isEqualTo( + new RateLimitOutcome.Unavailable( + "login", + Duration.ofMillis(100), + RateLimitOutcome.UnavailableCategory.NO_MUTATION_CONFIRMED)); + assertThat(executor.calls).isZero(); + } + + @Test + void failsClosedWhenTheStructuredReplyContradictsTheCompiledPolicy() { + CapturingExecutor executor = new CapturingExecutor(); + executor.reply = + new RedisRateProgramReply( + RedisRateProgramStatus.ALLOWED, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 99, + 98, + 0, + NOW.plusSeconds(1).toEpochMilli()); + RedisEdgeRateLimitProvider provider = + provider(Map.of("login", fixedPolicy("login", "r1")), executor); + + assertThat(provider.evaluate(request("login", 1))) + .isEqualTo( + new RateLimitOutcome.Incompatible( + "login", RateLimitOutcome.IncompatibleCategory.REPLY_INCOMPATIBLE)); + } + + @Test + void closeZeroizesOwnedHmacMaterialAndRejectsFurtherEvaluation() { + RedisEdgeRateLimitProvider provider = + provider(Map.of("login", fixedPolicy("login", "r1")), new CapturingExecutor()); + + provider.close(); + provider.close(); + + assertThat(provider.destroyed()).isTrue(); + assertThat(HMAC_SECRET).doesNotContain(0); + assertThatThrownBy(() -> provider.evaluate(request("login", 1))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + } + + private static RedisEdgeRateLimitProvider provider( + Map policies, CapturingExecutor executor) { + return new RedisEdgeRateLimitProvider( + policies, + RedisProgramCatalog.rateLimit(), + executor, + "worklog-api", + "test", + 1, + 1, + HMAC_SECRET, + CLOCK, + Duration.ofMillis(100)); + } + + private static RedisEdgeRateLimitProvider providerWithCommandBudget( + Map policies, + CapturingExecutor executor, + Duration minimumCallerBudget) { + return new RedisEdgeRateLimitProvider( + policies, + RedisProgramCatalog.rateLimit(), + executor, + "worklog-api", + "test", + 1, + 1, + HMAC_SECRET, + CLOCK, + Duration.ofMillis(100), + minimumCallerBudget); + } + + private static RateLimitRequest request(String policyId, long cost) { + return new RateLimitRequest(policyId, SUBJECT_DIGEST, cost, "", NOW.plus(Duration.ofHours(1))); + } + + private static RateLimitRequest request(String policyId, long cost, String evaluationId) { + return new RateLimitRequest( + policyId, SUBJECT_DIGEST, cost, evaluationId, NOW.plus(Duration.ofHours(1))); + } + + private static RateLimitPolicy fixedPolicy(String id, String revision) { + return new RateLimitPolicy( + id, + revision, + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(100, Duration.ofSeconds(1)), + 10, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED); + } + + private static RateLimitPolicy slidingPolicy(String id, String revision) { + return new RateLimitPolicy( + id, + revision, + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(100, Duration.ofSeconds(1)), + 10, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED); + } + + private static RateLimitPolicy tokenPolicy(String id, String revision) { + return new RateLimitPolicy( + id, + revision, + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(10, 10, Duration.ofSeconds(1)), + 10, + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED); + } + + private static void assertDecision( + RateLimitOutcome outcome, boolean allowed, long remaining, boolean certain) { + RateLimitDecision decision = ((RateLimitOutcome.Evaluated) outcome).decision(); + assertThat(decision.allowed()).isEqualTo(allowed); + assertThat(decision.remaining()).isEqualTo(remaining); + assertThat(decision.source()).isEqualTo(RateLimitDecision.DecisionSource.GLOBAL_REDIS); + assertThat(decision.certainty()) + .isEqualTo( + certain + ? RateLimitDecision.DecisionCertainty.CERTAIN + : RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM); + } + + private static List ascii(List values) { + return values.stream().map(value -> new String(value, US_ASCII)).toList(); + } + + private static final class CapturingExecutor implements RedisRateProgramExecutor { + + private RedisProgramDescriptor descriptor; + private List keys; + private List arguments; + private RedisRateProgramReply reply = + new RedisRateProgramReply( + RedisRateProgramStatus.ALLOWED, + NOW.toEpochMilli(), + NOW.toEpochMilli(), + 100, + 99, + 0, + NOW.plusSeconds(1).toEpochMilli()); + private RuntimeException failure; + private int indeterminateFailures; + private int calls; + private final java.util.ArrayList> invocationKeys = new java.util.ArrayList<>(); + private final java.util.ArrayList> invocationArguments = + new java.util.ArrayList<>(); + + @Override + public RedisRateProgramReply execute(RedisCatalogProgramInvocation invocation) { + calls++; + this.descriptor = invocation.descriptor(); + this.keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + this.arguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); + List keys = this.keys; + List arguments = this.arguments; + invocationKeys.add(keys); + invocationArguments.add(arguments); + if (indeterminateFailures > 0) { + indeterminateFailures--; + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "simulated response loss", + null); + } + if (failure != null) { + throw failure; + } + return reply; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfigTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfigTest.java new file mode 100644 index 0000000..10f18b2 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseConfigTest.java @@ -0,0 +1,181 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.application.lease.DistributedLeasePort; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +class RedisEfficiencyLeaseConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration(RedisEfficiencyLeaseConfig.class); + + @Test + void zeroBindingCreatesNoPortAndResolvesNoSecret() { + AtomicInteger resolutions = new AtomicInteger(); + runner + .withBean( + RedisCredentialMaterialProvider.class, + () -> + reference -> { + resolutions.incrementAndGet(); + throw new AssertionError("disabled lease resolved secret material"); + }) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(DistributedLeasePort.class)).isEmpty(); + assertThat(context.containsBean("distributedLeasePort")).isFalse(); + assertThat(resolutions).hasValue(0); + }); + } + + @Test + void explicitRedisProviderUsesOnlyTheCanonicalCoordinationRouter() { + AtomicInteger resolutions = new AtomicInteger(); + byte[] decoded = new byte[32]; + Arrays.fill(decoded, (byte) 9); + char[] encoded = Base64.getEncoder().encodeToString(decoded).toCharArray(); + Arrays.fill(decoded, (byte) 0); + + runner + .withBean(RedisCanonicalRoleRegistry.class, RedisEfficiencyLeaseConfigTest::registry) + .withBean( + RedisCredentialMaterialProvider.class, + () -> + reference -> { + resolutions.incrementAndGet(); + return new VersionedRedisCredentialMaterial( + "lease-hmac-v1", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from(encoded)); + }) + .withPropertyValues( + "ca-skeleton.capabilities.lease.provider=redis", + "ca-skeleton.capabilities.lease.key-hmac-secret-reference=secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.lease.namespace-environment=test") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(DistributedLeasePort.class); + assertThat(context).hasBean("distributedLeasePort"); + assertThat(resolutions).hasValue(1); + }); + + Arrays.fill(encoded, '\0'); + } + + @Test + void semanticPortBeanDeclaresProviderSecretDestroyLifecycle() { + Bean bean = + Arrays.stream(RedisEfficiencyLeaseConfig.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("distributedLeasePort")) + .findFirst() + .orElseThrow(() -> new AssertionError("distributed lease bean method missing")) + .getAnnotation(Bean.class); + + assertThat(bean).isNotNull(); + assertThat(bean.destroyMethod()).isEqualTo("close"); + } + + private static RedisCanonicalRoleRegistry registry() { + RedisClientRuntimeSettings clientSettings = + new RedisClientRuntimeSettings( + "lease-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + RedisDeploymentSettings.Standalone deployment = + new RedisDeploymentSettings.Standalone( + "coordination-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("coordination.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "coordination-runtime", "secret://environment/COORDINATION_REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls( + true, true, "secret://environment/COORDINATION_REDIS_TRUST_PEM")); + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, deployment), + clientSettings, + 8, + 65_536, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + ignored -> new NoOpRuntime()); + } + + private static final class NoOpRuntime implements RedisRoutableCommandRuntime { + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return "coordination-main"; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + ? RedisCatalogProgramReply.multi(List.of()) + : RedisCatalogProgramReply.value(null); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 0; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + return () -> {}; + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProviderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProviderTest.java new file mode 100644 index 0000000..8b269ac --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseProviderTest.java @@ -0,0 +1,228 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.lease.LeaseAcquireOutcome; +import dev.caskeleton.application.lease.LeaseAttempt; +import dev.caskeleton.application.lease.LeaseHandle; +import dev.caskeleton.application.lease.LeaseInspectionOutcome; +import dev.caskeleton.application.lease.LeaseInspectionRequest; +import dev.caskeleton.application.lease.LeaseReleaseOutcome; +import dev.caskeleton.application.lease.LeaseRenewOutcome; +import dev.caskeleton.application.lease.LeaseRequest; +import dev.caskeleton.application.lease.LeaseState; +import dev.caskeleton.application.lease.LeaseUnavailableCategory; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisEfficiencyLeaseProviderTest { + + private static final byte[] SECRET = "s".repeat(32).getBytes(US_ASCII); + private static final String OWNER = "owner_token_1234567890"; + private static final String OPERATION = "operation_token_12345"; + private static final String DIGEST = "hv1:" + "a".repeat(64); + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + + @Test + void emitsAcquireAndHandleMutationOutcomesWithoutOwnerOrResourceIdentity() { + FakeCommands commands = new FakeCommands(); + RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease(); + AtomicLong nanoTime = new AtomicLong(); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisEfficiencyLeaseProvider provider = + new RedisEfficiencyLeaseProvider( + new RedisLeaseKeyFactory("ca-skeleton", "test", 1, 1, SECRET), + new RedisLeaseProgramExecutor(catalog, commands), + new RedisLeaseTokenGenerator(new SecureRandom()), + Clock.fixed(NOW, ZoneOffset.UTC), + () -> nanoTime.getAndAdd(Duration.ofMillis(5).toNanos()), + Duration.ofMillis(5), + ignored -> {}, + observations); + commands.reply = reply("ACQUIRED", "5000", "10000", "15000", "1", OPERATION); + LeaseHandle handle = + ((LeaseAcquireOutcome.Acquired) + provider.tryAcquire(request(new LeaseAttempt(OWNER, OPERATION)))) + .handle(); + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "lost response " + OWNER, + null); + assertThat(handle.release()).isInstanceOf(LeaseReleaseOutcome.Indeterminate.class); + + assertThat(observations.operations()) + .extracting( + RedisCapabilityObservationEvent.OperationCompleted::operation, + RedisCapabilityObservationEvent.OperationCompleted::outcome, + RedisCapabilityObservationEvent.OperationCompleted::certainty) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.LEASE_ACQUIRE, + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.LEASE_RELEASE, + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE)); + assertThat(observations.operations().toString()).doesNotContain(OWNER, OPERATION, DIGEST); + } + + @Test + void acquiresReplaysAndReconcilesAnIndeterminateAcquireWithTheExactAttempt() { + FakeCommands commands = new FakeCommands(); + RedisEfficiencyLeaseProvider provider = provider(commands); + LeaseAttempt attempt = new LeaseAttempt(OWNER, OPERATION); + + commands.reply = reply("ACQUIRED", "5000", "10000", "15000", "1", OPERATION); + LeaseAcquireOutcome.Acquired acquired = + (LeaseAcquireOutcome.Acquired) provider.tryAcquire(request(attempt)); + assertThat(acquired.handle().state()).isEqualTo(LeaseState.ACTIVE); + assertThat(acquired.handle().observedServerExpiry()).isEqualTo(Instant.ofEpochMilli(15_000)); + assertThat(acquired.handle().remainingValidity()) + .isGreaterThan(Duration.ofMillis(4_900)) + .isLessThan(Duration.ofSeconds(5)); + assertThat(new String(commands.key, US_ASCII)) + .startsWith("ca:ca-skeleton:test:lease:daily-export:") + .doesNotContain(DIGEST); + + commands.reply = reply("REPLAYED_SAME_OPERATION", "4500", "10500", "15000", "1", OPERATION); + assertThat(provider.tryAcquire(request(attempt))) + .isInstanceOf(LeaseAcquireOutcome.ReplayedSameOperation.class); + + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "lost response", + null); + assertThat(provider.tryAcquire(request(attempt))) + .isEqualTo(new LeaseAcquireOutcome.Indeterminate(OPERATION)); + + commands.failure = null; + commands.reply = reply("OWNED", "4000", "11000", "15000", "1", OPERATION); + assertThat(provider.inspect(new LeaseInspectionRequest("daily-export", DIGEST, attempt))) + .isInstanceOf(LeaseInspectionOutcome.Owned.class); + } + + @Test + void renewAndReleaseRejectAnOldOwnerWithoutMutatingTheReplacement() { + FakeCommands commands = new FakeCommands(); + RedisEfficiencyLeaseProvider provider = provider(commands); + commands.reply = reply("ACQUIRED", "5000", "10000", "15000", "1", OPERATION); + LeaseHandle old = + ((LeaseAcquireOutcome.Acquired) + provider.tryAcquire(request(new LeaseAttempt(OWNER, OPERATION)))) + .handle(); + + commands.reply = reply("NOT_OWNER", "5000", "20000", "25000", "2", "-"); + assertThat(old.renew(Duration.ofSeconds(5))).isInstanceOf(LeaseRenewOutcome.NotOwner.class); + assertThat(old.state()).isEqualTo(LeaseState.LOST); + assertThat(old.release()).isInstanceOf(LeaseReleaseOutcome.NotOwner.class); + assertThat(commands.arguments) + .extracting(value -> new String(value, US_ASCII)) + .containsExactly("1", OWNER, OPERATION); + } + + @Test + void mutationResponseLossMovesTheHandleToUnknownAndRequiresInspection() { + FakeCommands commands = new FakeCommands(); + RedisEfficiencyLeaseProvider provider = provider(commands); + LeaseAttempt attempt = new LeaseAttempt(OWNER, OPERATION); + commands.reply = reply("ACQUIRED", "5000", "10000", "15000", "1", OPERATION); + LeaseHandle handle = + ((LeaseAcquireOutcome.Acquired) provider.tryAcquire(request(attempt))).handle(); + + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "lost renew response", + null); + assertThat(handle.renew(Duration.ofSeconds(5))) + .isEqualTo(new LeaseRenewOutcome.Indeterminate(OPERATION)); + assertThat(handle.state()).isEqualTo(LeaseState.UNKNOWN); + + commands.failure = null; + commands.reply = reply("OWNED", "4500", "10500", "15000", "1", OPERATION); + assertThat(provider.inspect(new LeaseInspectionRequest("daily-export", DIGEST, attempt))) + .isInstanceOf(LeaseInspectionOutcome.Owned.class); + } + + @Test + void malformedLiveReplyFailsClosedAndProviderCloseDestroysKeyMaterial() { + FakeCommands commands = new FakeCommands(); + RedisEfficiencyLeaseProvider provider = provider(commands); + LeaseAttempt attempt = new LeaseAttempt(OWNER, OPERATION); + commands.reply = reply("CONTENDED", "0", "10000", "10000", "1", "-"); + + assertThat(provider.tryAcquire(request(attempt))) + .isEqualTo( + new LeaseAcquireOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND)); + + provider.close(); + assertThat(provider.destroyed()).isTrue(); + assertThatThrownBy(() -> provider.newAttempt("closed_operation_token1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + assertThat(provider.tryAcquire(request(attempt))) + .isEqualTo( + new LeaseAcquireOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND)); + } + + private static RedisEfficiencyLeaseProvider provider(FakeCommands commands) { + RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease(); + AtomicLong nanoTime = new AtomicLong(); + return new RedisEfficiencyLeaseProvider( + new RedisLeaseKeyFactory("ca-skeleton", "test", 1, 1, SECRET), + new RedisLeaseProgramExecutor(catalog, commands), + new RedisLeaseTokenGenerator(new SecureRandom()), + Clock.fixed(NOW, ZoneOffset.UTC), + () -> nanoTime.getAndAdd(Duration.ofMillis(5).toNanos()), + Duration.ofMillis(5), + ignored -> {}); + } + + private static LeaseRequest request(LeaseAttempt attempt) { + return new LeaseRequest("daily-export", DIGEST, Duration.ZERO, Duration.ofSeconds(5), attempt); + } + + private static List reply(String... values) { + return java.util.Arrays.stream(values).map(value -> value.getBytes(US_ASCII)).toList(); + } + + private static final class FakeCommands implements RedisStructuredCommands { + + private List reply = reply("ABSENT", "0", "0", "0", "0", "-"); + private RuntimeException failure; + private byte[] key; + private List arguments; + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + throw new AssertionError("unit fake should not require SCRIPT LOAD recovery"); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + List keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + List actualArguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); + key = keys.getFirst(); + arguments = actualArguments; + if (failure != null) { + throw failure; + } + return RedisCatalogProgramReply.multi(reply); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseRuntimeServiceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseRuntimeServiceTest.java new file mode 100644 index 0000000..dc316fb --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisEfficiencyLeaseRuntimeServiceTest.java @@ -0,0 +1,206 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.lease.LeaseAcquireOutcome; +import dev.caskeleton.application.lease.LeaseAttempt; +import dev.caskeleton.application.lease.LeaseInspectionOutcome; +import dev.caskeleton.application.lease.LeaseInspectionRequest; +import dev.caskeleton.application.lease.LeaseReleaseOutcome; +import dev.caskeleton.application.lease.LeaseRequest; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-service") +class RedisEfficiencyLeaseRuntimeServiceTest { + + private static final byte[] SECRET = "l".repeat(32).getBytes(US_ASCII); + + @Test + void concurrentAttemptsHaveExactlyOneOwnerAndSameAttemptReplays() throws Exception { + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings()); + RedisEfficiencyLeaseProvider provider = provider(runtime); + var executor = Executors.newFixedThreadPool(2)) { + String digest = digest("lease-concurrent-" + System.nanoTime()); + LeaseAttempt first = provider.newAttempt("first_operation_token_123"); + LeaseAttempt second = provider.newAttempt("second_operation_token_12"); + Callable firstAcquire = + () -> provider.tryAcquire(request(digest, first)); + Callable secondAcquire = + () -> provider.tryAcquire(request(digest, second)); + + List outcomes = + executor.invokeAll(List.of(firstAcquire, secondAcquire)).stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception failure) { + throw new AssertionError(failure); + } + }) + .toList(); + + assertThat(outcomes).filteredOn(LeaseAcquireOutcome.Acquired.class::isInstance).hasSize(1); + assertThat(outcomes).filteredOn(LeaseAcquireOutcome.Contended.class::isInstance).hasSize(1); + LeaseAcquireOutcome.Acquired acquired = + (LeaseAcquireOutcome.Acquired) + outcomes.stream() + .filter(LeaseAcquireOutcome.Acquired.class::isInstance) + .findFirst() + .orElseThrow(); + LeaseAttempt winner = + new LeaseAttempt(acquired.handle().ownerToken(), acquired.handle().operationId()); + assertThat(provider.tryAcquire(request(digest, winner))) + .isInstanceOf(LeaseAcquireOutcome.ReplayedSameOperation.class); + } + } + + @Test + void lostAcquireResponseIsRecoveredOnlyByTheRetainedAttempt() { + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings())) { + String digest = digest("lease-lost-response-" + System.nanoTime()); + LeaseAttempt attempt = new LeaseAttempt("lost_response_owner_123", "lost_response_operation"); + try (RedisEfficiencyLeaseProvider lossy = + provider(new LoseFirstSuccessfulReplyCommands(runtime))) { + assertThat(lossy.tryAcquire(request(digest, attempt))) + .isEqualTo(new LeaseAcquireOutcome.Indeterminate(attempt.operationId())); + } + try (RedisEfficiencyLeaseProvider reconciler = provider(runtime)) { + assertThat(reconciler.inspect(new LeaseInspectionRequest("daily-export", digest, attempt))) + .isInstanceOf(LeaseInspectionOutcome.Owned.class); + } + } + } + + @Test + void expiredOldOwnerCannotReleaseTheReplacementOwner() throws InterruptedException { + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings()); + RedisEfficiencyLeaseProvider provider = provider(runtime)) { + String digest = digest("lease-expiry-" + System.nanoTime()); + LeaseAttempt oldAttempt = + new LeaseAttempt("expired_owner_token_123", "expired_operation_token"); + LeaseAcquireOutcome.Acquired oldAcquire = + (LeaseAcquireOutcome.Acquired) + provider.tryAcquire( + new LeaseRequest( + "daily-export", digest, Duration.ZERO, Duration.ofMillis(100), oldAttempt)); + Thread.sleep(180); + + LeaseAttempt replacement = + new LeaseAttempt("replacement_owner_token_1", "replacement_operation_1"); + LeaseAcquireOutcome.Acquired replacementAcquire = + (LeaseAcquireOutcome.Acquired) + provider.tryAcquire( + new LeaseRequest( + "daily-export", digest, Duration.ZERO, Duration.ofSeconds(2), replacement)); + assertThat(oldAcquire.handle().release()).isInstanceOf(LeaseReleaseOutcome.NotOwner.class); + assertThat(provider.inspect(new LeaseInspectionRequest("daily-export", digest, replacement))) + .isInstanceOf(LeaseInspectionOutcome.Owned.class); + assertThat(replacementAcquire.handle().release()) + .isInstanceOf(LeaseReleaseOutcome.Released.class); + } + } + + @Test + void malformedAcquireCannotCreateLeaseState() { + byte[] key = ("ca:test:lease:{malformed-" + System.nanoTime() + "}:owner").getBytes(US_ASCII); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings())) { + RedisLeaseProgramReply reply = + new RedisLeaseProgramExecutor(RedisProgramCatalog.efficiencyLease(), runtime) + .execute( + RedisProgramTestInvocations.lease( + RedisProgramId.LEASE_ACQUIRE_V1, + key, + List.of( + "1".getBytes(US_ASCII), + "short".getBytes(US_ASCII), + "operation_token_12345".getBytes(US_ASCII), + "1000".getBytes(US_ASCII)))); + + assertThat(reply.status()).isEqualTo("INVALID"); + assertThat(runtime.get(RedisPhysicalKeyTestFactory.fromEncoded(key))).isNull(); + } + } + + private static RedisEfficiencyLeaseProvider provider(RedisStructuredCommands commands) { + return RedisEfficiencyLeaseProvider.create( + "ca-skeleton", "test", 1, 1, SECRET, commands, Clock.systemUTC(), Duration.ofMillis(10)); + } + + private static LeaseRequest request(String digest, LeaseAttempt attempt) { + return new LeaseRequest("daily-export", digest, Duration.ZERO, Duration.ofSeconds(2), attempt); + } + + private static String digest(String value) { + try { + byte[] hash = + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return "hv1:" + HexFormat.of().formatHex(hash); + } catch (java.security.NoSuchAlgorithmException failure) { + throw new AssertionError(failure); + } + } + + private static RedisLegacyStandaloneSettings settings() { + return new RedisLegacyStandaloneSettings( + requiredProperty("redis.test.host"), + Integer.parseInt(requiredProperty("redis.test.port")), + "", + Base64.getEncoder().encodeToString(SECRET), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new AssertionError("real Redis lane requires -D" + name); + } + return value; + } + + private static final class LoseFirstSuccessfulReplyCommands implements RedisStructuredCommands { + + private final RedisStructuredCommands delegate; + private boolean lost; + + private LoseFirstSuccessfulReplyCommands(RedisStructuredCommands delegate) { + this.delegate = delegate; + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return delegate.loadCatalogProgram(invocation); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + RedisCatalogProgramReply reply = delegate.executeCatalogProgram(invocation); + if (!lost) { + lost = true; + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "simulated lost lease response", + null); + } + return reply; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfigTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfigTest.java new file mode 100644 index 0000000..9e424d1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyConfigTest.java @@ -0,0 +1,179 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.application.idempotency.IdempotencyExecutorV2; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +class RedisIdempotencyConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration(RedisIdempotencyConfig.class); + + @Test + void nonRedisSelectionCreatesNoV2PortOrExecutorAndResolvesNoSecret() { + AtomicInteger resolutions = new AtomicInteger(); + runner + .withBean( + RedisCredentialMaterialProvider.class, + () -> + reference -> { + resolutions.incrementAndGet(); + throw new AssertionError("unselected Redis idempotency resolved secret material"); + }) + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=jdbc") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(IdempotencyStorePortV2.class)).isEmpty(); + assertThat(context.getBeansOfType(IdempotencyExecutorV2.class)).isEmpty(); + assertThat(resolutions).hasValue(0); + }); + } + + @Test + void v2StoreBeanDeclaresProviderSecretDestroyLifecycle() { + Bean bean = + Arrays.stream(RedisIdempotencyConfig.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("redisIdempotencyStoreV2")) + .findFirst() + .orElseThrow(() -> new AssertionError("Redis idempotency store bean missing")) + .getAnnotation(Bean.class); + + assertThat(bean).isNotNull(); + assertThat(bean.destroyMethod()).isEqualTo("close"); + } + + @Test + void selectedRedisV2UsesTheCanonicalCoordinationRole() { + byte[] decoded = new byte[32]; + Arrays.fill(decoded, (byte) 3); + char[] encoded = Base64.getEncoder().encodeToString(decoded).toCharArray(); + Arrays.fill(decoded, (byte) 0); + + runner + .withBean(RedisCanonicalRoleRegistry.class, RedisIdempotencyConfigTest::registry) + .withBean( + RedisCredentialMaterialProvider.class, + () -> + reference -> + new VersionedRedisCredentialMaterial( + "idempotency-hmac-v1", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from(encoded))) + .withPropertyValues( + "ca-skeleton.capabilities.idempotency.provider=redis", + "ca-skeleton.capabilities.idempotency.key-hmac-secret-reference=secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.idempotency.namespace-environment=test") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(IdempotencyStorePortV2.class); + assertThat(context).hasSingleBean(IdempotencyExecutorV2.class); + }); + + Arrays.fill(encoded, '\0'); + } + + private static RedisCanonicalRoleRegistry registry() { + RedisClientRuntimeSettings clientSettings = + new RedisClientRuntimeSettings( + "idempotency-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + RedisDeploymentSettings.Standalone deployment = + new RedisDeploymentSettings.Standalone( + "coordination-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("coordination.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "coordination-runtime", "secret://environment/COORDINATION_REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls( + true, true, "secret://environment/COORDINATION_REDIS_TRUST_PEM")); + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, deployment), + clientSettings, + 8, + 65_536, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + ignored -> new NoOpRuntime()); + } + + private static final class NoOpRuntime implements RedisRoutableCommandRuntime { + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return "coordination-main"; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + ? RedisCatalogProgramReply.multi(List.of()) + : RedisCatalogProgramReply.value(null); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 0; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + return () -> {}; + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramCatalogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramCatalogTest.java new file mode 100644 index 0000000..26f80e0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyProgramCatalogTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisIdempotencyProgramCatalogTest { + + @Test + void ownsTheSevenCanonicalV1ProgramsWithOneBoundedRecordKey() { + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + + assertThat(catalog.descriptors()) + .extracting(RedisProgramDescriptor::id) + .containsExactlyInAnyOrder( + RedisProgramId.IDEMPOTENCY_CLAIM_V1, + RedisProgramId.IDEMPOTENCY_START_V1, + RedisProgramId.IDEMPOTENCY_RENEW_V1, + RedisProgramId.IDEMPOTENCY_COMPLETE_V1, + RedisProgramId.IDEMPOTENCY_FAIL_V1, + RedisProgramId.IDEMPOTENCY_RELEASE_V1, + RedisProgramId.IDEMPOTENCY_INSPECT_V1); + assertThat(catalog.descriptors()) + .allSatisfy( + descriptor -> { + assertThat(descriptor.id().externalId()).endsWith("-v1"); + assertThat(descriptor.keyCount()).isEqualTo(1); + assertThat(descriptor.replyFieldCount()).isEqualTo(6); + assertThat(descriptor.sha256()).matches("[0-9a-f]{64}"); + assertThat(descriptor.scriptBytes().length).isLessThan(16_384); + assertThat(new String(descriptor.scriptBytes(), UTF_8)) + .contains("stateRevision") + .contains("updatedAtMillis") + .contains("STATE_INCOMPATIBLE"); + }); + } + + @Test + void claimDeclaresConflictRecoveryAndReplayWithoutExactlyOnceLanguage() { + RedisProgramDescriptor claim = + RedisProgramCatalog.idempotencyV2().descriptor(RedisProgramId.IDEMPOTENCY_CLAIM_V1); + + assertThat(claim.statuses()) + .containsAll( + Set.of( + "OWNER_OPERATION_CONFLICT", + "RECOVERY_REQUIRED", + "COMPLETED_REPLAY", + "STATE_INCOMPATIBLE")); + assertThat(new String(claim.scriptBytes(), UTF_8).toLowerCase(Locale.ROOT)) + .doesNotContain("exactly-once") + .contains("abandoned_effect_unknown"); + } + + @Test + void candidateManifestPinsEveryDigestAndExplicitlyDisclaimsExactlyOnce() throws IOException { + String manifest; + try (InputStream input = + getClass().getClassLoader().getResourceAsStream("redis/idempotency-program-set.json")) { + assertThat(input).isNotNull(); + manifest = new String(input.readAllBytes(), UTF_8); + } + + assertThat(JsonPath.read(manifest, "$.readiness")).isEqualTo("CANDIDATE"); + assertThat(JsonPath.read(manifest, "$.exactlyOnceScope")).isEqualTo("NONE"); + List> programs = JsonPath.read(manifest, "$.programs"); + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + assertThat(programs).hasSameSizeAs(catalog.descriptors()); + programs.forEach( + entry -> { + RedisProgramDescriptor descriptor = + catalog.descriptors().stream() + .filter(candidate -> candidate.id().externalId().equals(entry.get("id"))) + .findFirst() + .orElseThrow(); + assertThat(entry.get("sha256")).isEqualTo(descriptor.sha256()); + assertThat(entry.get("argumentCount")).isEqualTo(descriptor.argumentCount()); + assertThat(Set.copyOf((List) entry.get("statuses"))).isEqualTo(descriptor.statuses()); + }); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodecTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodecTest.java new file mode 100644 index 0000000..37b5888 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRecordCodecTest.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.StoredResponse; +import org.junit.jupiter.api.Test; + +class RedisIdempotencyRecordCodecTest { + + private static final byte[] SECRET = "x".repeat(32).getBytes(UTF_8); + + @Test + void responseCodecRoundTripsUnicodeAndPinsAStableDigest() { + RedisIdempotencyRecordCodec codec = new RedisIdempotencyRecordCodec(); + + RedisIdempotencyRecordCodec.EncodedResponse encoded = + codec.encode(new StoredResponse("완료-response")); + + assertThat(encoded.payload()).matches("[A-Za-z0-9_-]+"); + assertThat(encoded.digest()).matches("[0-9a-f]{64}"); + assertThat(codec.decode(encoded.payload(), encoded.digest())) + .isEqualTo(new StoredResponse("완료-response")); + RedisIdempotencyRecordCodec.EncodedResponse empty = codec.encode(new StoredResponse("")); + assertThat(empty.payload()).isEqualTo("-"); + assertThat(codec.decode(empty.payload(), empty.digest())).isEqualTo(new StoredResponse("")); + } + + @Test + void rejectsOversizedAndMalformedPayloadsBeforeRedis() { + RedisIdempotencyRecordCodec codec = new RedisIdempotencyRecordCodec(); + + assertThatThrownBy( + () -> + codec.encode( + new StoredResponse( + "x".repeat(RedisIdempotencyRecordCodec.MAXIMUM_PAYLOAD_BYTES + 1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payload bound"); + assertThatThrownBy(() -> codec.decode("%%%", "0".repeat(64))) + .isInstanceOf(RedisProgramCompatibilityException.class); + assertThatThrownBy( + () -> { + RedisIdempotencyRecordCodec.EncodedResponse encoded = + codec.encode(new StoredResponse("response")); + codec.decode(encoded.payload(), "0".repeat(64)); + }) + .isInstanceOf(RedisProgramCompatibilityException.class); + } + + @Test + void scopeKeyIsHmacBoundedAndDoesNotLeakAnyScopeDimension() { + RedisIdempotencyKeyFactory keys = + new RedisIdempotencyKeyFactory("worklog-api", "test", 1, 1, SECRET); + IdempotencyScope scope = + IdempotencyScope.of("tenant-a", "principal-a", "request-key-a", "create-worklog"); + + String physical = new String(keys.physicalKey(scope), UTF_8); + + assertThat(physical) + .startsWith("ca:worklog-api:test:idempotency:request:hv1:kv1:{") + .endsWith(":record") + .doesNotContain("tenant-a") + .doesNotContain("principal-a") + .doesNotContain("request-key-a") + .doesNotContain("create-worklog"); + assertThat(keys.physicalKey(scope)).containsExactly(keys.physicalKey(scope)); + assertThat( + keys.physicalKey(IdempotencyScope.of("principal-a", "request-key-b", "create-worklog"))) + .isNotEqualTo(keys.physicalKey(scope)); + + keys.close(); + assertThat(keys.destroyed()).isTrue(); + assertThatThrownBy(() -> keys.physicalKey(scope)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRuntimeServiceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRuntimeServiceTest.java new file mode 100644 index 0000000..d253898 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyRuntimeServiceTest.java @@ -0,0 +1,370 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.IdempotencyInspection; +import dev.caskeleton.application.idempotency.IdempotencyInspectionRequest; +import dev.caskeleton.application.idempotency.IdempotencyOwner; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStartOutcome; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-service") +class RedisIdempotencyRuntimeServiceTest { + + private static final byte[] SECRET = new byte[32]; + private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("a".repeat(64)); + + @Test + void realRedisReplaysEverySameOperationAndDetectsResponseDigestConflict() { + RedisLegacyStandaloneSettings settings = settings(); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings); + RedisIdempotencyStoreProvider provider = provider(runtime)) { + IdempotencyScope scope = scope("replay"); + IdempotencyClaimAttempt attempt = + new IdempotencyClaimAttempt("owner_token_1234567890", "operation_token_12345"); + IdempotencyClaimRequest request = request(scope, attempt, Duration.ofSeconds(2)); + + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) provider.claim(request); + assertThat(provider.claim(request)) + .isInstanceOf(IdempotencyClaimOutcome.ReplayedAcquire.class); + assertThat(provider.markExecutionStarted(acquired.owner(), attempt.operationId()).status()) + .isEqualTo(IdempotencyStartOutcome.Status.STARTED); + assertThat(provider.markExecutionStarted(acquired.owner(), attempt.operationId()).status()) + .isEqualTo(IdempotencyStartOutcome.Status.ALREADY_STARTED_SAME_OPERATION); + assertThat( + provider + .complete( + acquired.owner(), + new StoredResponse("created"), + Duration.ofSeconds(2), + attempt.operationId()) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.COMPLETED); + assertThat( + provider + .complete( + acquired.owner(), + new StoredResponse("created"), + Duration.ofSeconds(2), + attempt.operationId()) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.ALREADY_COMPLETED_SAME_RESULT); + assertThat( + provider + .complete( + acquired.owner(), + new StoredResponse("different"), + Duration.ofSeconds(2), + attempt.operationId()) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.RESPONSE_CONFLICT); + + IdempotencyClaimAttempt duplicate = + new IdempotencyClaimAttempt("other_owner_token_12345", "other_operation_token_1"); + IdempotencyClaimOutcome.CompletedReplay replay = + (IdempotencyClaimOutcome.CompletedReplay) + provider.claim(request(scope, duplicate, Duration.ofSeconds(2))); + assertThat(replay.response()).isEqualTo(new StoredResponse("created")); + assertThat(provider.inspect(new IdempotencyInspectionRequest(scope, FINGERPRINT, attempt))) + .isInstanceOf(IdempotencyInspection.CompletedReplay.class); + } + } + + @Test + void lostCompletionResponseIsReconciledByTheExactSameOperation() { + RedisLegacyStandaloneSettings settings = settings(); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings); + RedisIdempotencyStoreProvider provider = provider(runtime)) { + IdempotencyScope scope = scope("lost-completion-response"); + IdempotencyClaimAttempt attempt = + new IdempotencyClaimAttempt("lost_response_owner_123", "lost_response_operation"); + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) + provider.claim(request(scope, attempt, Duration.ofSeconds(2))); + assertThat(provider.markExecutionStarted(acquired.owner(), attempt.operationId()).status()) + .isEqualTo(IdempotencyStartOutcome.Status.STARTED); + + try (RedisIdempotencyStoreProvider lossyProvider = + provider(new LoseFirstSuccessfulReplyCommands(runtime))) { + assertThat( + lossyProvider + .complete( + acquired.owner(), + new StoredResponse("created"), + Duration.ofSeconds(2), + attempt.operationId()) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.INDETERMINATE); + assertThat( + lossyProvider + .complete( + acquired.owner(), + new StoredResponse("created"), + Duration.ofSeconds(2), + attempt.operationId()) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.ALREADY_COMPLETED_SAME_RESULT); + } + + assertThat(provider.inspect(new IdempotencyInspectionRequest(scope, FINGERPRINT, attempt))) + .isInstanceOf(IdempotencyInspection.CompletedReplay.class); + } + } + + @Test + void concurrentClaimsHaveOneWinnerAndOneInProgressObserver() throws Exception { + RedisLegacyStandaloneSettings settings = settings(); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings); + RedisIdempotencyStoreProvider provider = provider(runtime); + var executor = Executors.newFixedThreadPool(2)) { + IdempotencyScope scope = scope("concurrent"); + Callable first = + () -> + provider.claim( + request( + scope, + new IdempotencyClaimAttempt( + "first_owner_token_1234", "first_operation_token_1"), + Duration.ofSeconds(2))); + Callable second = + () -> + provider.claim( + request( + scope, + new IdempotencyClaimAttempt( + "second_owner_token_123", "second_operation_token_1"), + Duration.ofSeconds(2))); + + List outcomes = + executor.invokeAll(List.of(first, second)).stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception exception) { + throw new AssertionError(exception); + } + }) + .toList(); + + assertThat(outcomes) + .filteredOn(IdempotencyClaimOutcome.Acquired.class::isInstance) + .hasSize(1); + assertThat(outcomes) + .filteredOn(IdempotencyClaimOutcome.InProgress.class::isInstance) + .hasSize(1); + } + } + + @Test + void expiredClaimedIsTakenOverButExpiredExecutingBecomesRecoveryRequired() + throws InterruptedException { + RedisLegacyStandaloneSettings settings = settings(); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings); + RedisIdempotencyStoreProvider provider = provider(runtime)) { + IdempotencyScope claimedScope = scope("expired-claimed"); + IdempotencyClaimAttempt first = + new IdempotencyClaimAttempt("claimed_owner_token_123", "claimed_operation_token_1"); + provider.claim(request(claimedScope, first, Duration.ofMillis(100))); + Thread.sleep(160); + IdempotencyClaimAttempt takeover = + new IdempotencyClaimAttempt("takeover_owner_token_12", "takeover_operation_tok"); + IdempotencyClaimOutcome.TakenOverClaimed takenOver = + (IdempotencyClaimOutcome.TakenOverClaimed) + provider.claim(request(claimedScope, takeover, Duration.ofMillis(100))); + assertThat(takenOver.owner().attempt()).isEqualTo(2); + + IdempotencyScope executingScope = scope("expired-executing"); + IdempotencyClaimAttempt executing = + new IdempotencyClaimAttempt("executing_owner_token_1", "executing_operation_tok"); + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) + provider.claim(request(executingScope, executing, Duration.ofMillis(100))); + assertThat(provider.markExecutionStarted(acquired.owner(), executing.operationId()).status()) + .isEqualTo(IdempotencyStartOutcome.Status.STARTED); + Thread.sleep(160); + IdempotencyClaimOutcome recovery = + provider.claim( + request( + executingScope, + new IdempotencyClaimAttempt("recovery_owner_token_123", "recovery_operation_tok"), + Duration.ofMillis(100))); + assertThat(recovery).isInstanceOf(IdempotencyClaimOutcome.RecoveryRequired.class); + assertThat( + provider.inspect( + new IdempotencyInspectionRequest(executingScope, FINGERPRINT, executing))) + .isInstanceOf(IdempotencyInspection.Abandoned.class); + } + } + + @Test + void malformedAndOversizedInputsCannotCreateARecord() { + RedisLegacyStandaloneSettings settings = settings(); + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + byte[] key = "ca:test:idempotency:{invalid}:record".getBytes(US_ASCII); + try (LettuceRedisRuntime runtime = LettuceRedisRuntime.connect(settings); + RedisIdempotencyStoreProvider provider = provider(runtime)) { + RedisIdempotencyProgramReply invalid = + new RedisIdempotencyProgramExecutor(catalog, runtime) + .execute( + RedisProgramTestInvocations.idempotency( + RedisProgramId.IDEMPOTENCY_CLAIM_V1, + key, + List.of( + "2".getBytes(US_ASCII), + "invalid".getBytes(US_ASCII), + "owner_token_1234567890".getBytes(US_ASCII), + "operation_token_12345".getBytes(US_ASCII), + "100".getBytes(US_ASCII), + "2000".getBytes(US_ASCII), + "json-v2".getBytes(US_ASCII), + "policy-v2".getBytes(US_ASCII)))); + assertThat(invalid.status()).isEqualTo("INVALID"); + assertThat(runtime.get(RedisPhysicalKeyTestFactory.fromEncoded(key))).isNull(); + + IdempotencyScope malformedCompleteScope = scope("malformed-complete"); + IdempotencyClaimAttempt malformedCompleteAttempt = + new IdempotencyClaimAttempt("malformed_owner_token_1", "malformed_operation_tok"); + IdempotencyClaimOutcome.Acquired malformedCompleteOwner = + (IdempotencyClaimOutcome.Acquired) + provider.claim( + request(malformedCompleteScope, malformedCompleteAttempt, Duration.ofSeconds(2))); + assertThat( + provider + .markExecutionStarted( + malformedCompleteOwner.owner(), malformedCompleteAttempt.operationId()) + .status()) + .isEqualTo(IdempotencyStartOutcome.Status.STARTED); + try (RedisIdempotencyKeyFactory keys = + new RedisIdempotencyKeyFactory("ca-skeleton", "test", 1, 1, SECRET)) { + RedisIdempotencyProgramReply invalidComplete = + new RedisIdempotencyProgramExecutor(catalog, runtime) + .execute( + RedisProgramTestInvocations.idempotency( + RedisProgramId.IDEMPOTENCY_COMPLETE_V1, + keys.physicalKey(malformedCompleteScope), + List.of( + "2".getBytes(US_ASCII), + malformedCompleteOwner.owner().ownerToken().getBytes(US_ASCII), + "1".getBytes(US_ASCII), + "%%%".getBytes(US_ASCII), + "0".repeat(64).getBytes(US_ASCII), + "2000".getBytes(US_ASCII), + malformedCompleteAttempt.operationId().getBytes(US_ASCII)))); + assertThat(invalidComplete.status()).isEqualTo("INVALID"); + } + assertThat( + provider.inspect( + new IdempotencyInspectionRequest( + malformedCompleteScope, FINGERPRINT, malformedCompleteAttempt))) + .isInstanceOf(IdempotencyInspection.ExecutingSameOperation.class); + + assertThatThrownBy( + () -> + provider.complete( + new IdempotencyOwner(scope("oversized"), "owner_token_1234567890", 1), + new StoredResponse( + "x".repeat(RedisIdempotencyRecordCodec.MAXIMUM_PAYLOAD_BYTES + 1)), + Duration.ofSeconds(2), + "operation_token_12345")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payload bound"); + } + } + + private static RedisIdempotencyStoreProvider provider(LettuceRedisRuntime runtime) { + return provider((RedisStructuredCommands) runtime); + } + + private static RedisIdempotencyStoreProvider provider(RedisStructuredCommands commands) { + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + return new RedisIdempotencyStoreProvider( + new RedisIdempotencyKeyFactory("ca-skeleton", "test", 1, 1, SECRET), + new RedisIdempotencyProgramExecutor(catalog, commands), + new RedisIdempotencyRecordCodec(), + new RedisIdempotencyTokenGenerator(new SecureRandom())); + } + + private static final class LoseFirstSuccessfulReplyCommands implements RedisStructuredCommands { + + private final RedisStructuredCommands delegate; + private boolean lost; + + private LoseFirstSuccessfulReplyCommands(RedisStructuredCommands delegate) { + this.delegate = delegate; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + RedisCatalogProgramReply reply = delegate.executeCatalogProgram(invocation); + return RedisCatalogProgramReply.multi(lose(reply.copyFields())); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return delegate.loadCatalogProgram(invocation); + } + + private List lose(List reply) { + if (!lost) { + lost = true; + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "simulated lost Redis response", + null); + } + return reply; + } + } + + private static IdempotencyClaimRequest request( + IdempotencyScope scope, IdempotencyClaimAttempt attempt, Duration processingTtl) { + return new IdempotencyClaimRequest( + scope, FINGERPRINT, attempt, processingTtl, Duration.ofSeconds(5), "json-v2", "policy-v2"); + } + + private static IdempotencyScope scope(String suffix) { + return IdempotencyScope.of("principal-" + suffix, "key-" + suffix, "create-worklog"); + } + + private static RedisLegacyStandaloneSettings settings() { + return new RedisLegacyStandaloneSettings( + requiredProperty("redis.test.host"), + Integer.parseInt(requiredProperty("redis.test.port")), + "", + Base64.getEncoder().encodeToString(SECRET), + Duration.ofSeconds(2), + 16_384, + 32, + 1_048_576, + "ca-skeleton", + "test"); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new AssertionError("real Redis lane requires -D" + name); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettingsTest.java new file mode 100644 index 0000000..af81185 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencySettingsTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class RedisIdempotencySettingsTest { + + @Test + void disabledSettingsNeedNoSecretAndUseBoundedRequestReplayDefaults() { + RedisIdempotencySettings settings = + new RedisIdempotencySettings(null, null, null, null, 0, 0, null, null, null, null, null); + + assertThat(settings.provider()).isEmpty(); + assertThat(settings.processingLease()).isEqualTo(Duration.ofSeconds(30)); + assertThat(settings.replayTtl()).isEqualTo(Duration.ofHours(24)); + assertThat(settings.failureRetention()).isEqualTo(Duration.ofHours(24)); + } + + @Test + void binderCompilesTheExplicitRedisV2Policy() { + RedisIdempotencySettings settings = + new Binder( + new MapConfigurationPropertySource( + Map.of( + "ca-skeleton.capabilities.idempotency.provider", + "redis", + "ca-skeleton.capabilities.idempotency.key-hmac-secret-reference", + "secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.idempotency.processing-lease", + "45s", + "ca-skeleton.capabilities.idempotency.replay-ttl", + "12h"))) + .bind( + "ca-skeleton.capabilities.idempotency", Bindable.of(RedisIdempotencySettings.class)) + .orElseThrow(() -> new AssertionError("idempotency settings did not bind")); + + settings.validateActive(); + assertThat(settings.processingLease()).isEqualTo(Duration.ofSeconds(45)); + assertThat(settings.replayTtl()).isEqualTo(Duration.ofHours(12)); + } + + @Test + void replayRetentionMustOutliveTheProcessingLeaseAndActiveSecretMustBeAReference() { + assertThatThrownBy( + () -> + new RedisIdempotencySettings( + "redis", + "secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET", + "ca-skeleton", + "test", + 1, + 1, + Duration.ofMinutes(1), + Duration.ofMinutes(1), + Duration.ofHours(1), + "json-v2", + "policy-v2")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("outlive"); + + RedisIdempotencySettings inline = + new RedisIdempotencySettings( + "redis", + "inline-secret", + "ca-skeleton", + "test", + 1, + 1, + Duration.ofSeconds(30), + Duration.ofHours(1), + Duration.ofHours(1), + "json-v2", + "policy-v2"); + assertThatThrownBy(inline::validateActive) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("secret"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProviderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProviderTest.java new file mode 100644 index 0000000..03980ec --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisIdempotencyStoreProviderTest.java @@ -0,0 +1,304 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.IdempotencyInspection; +import dev.caskeleton.application.idempotency.IdempotencyInspectionRequest; +import dev.caskeleton.application.idempotency.IdempotencyOwner; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisIdempotencyStoreProviderTest { + + private static final IdempotencyScope SCOPE = + IdempotencyScope.of("principal-digest", "request-key", "create-worklog"); + private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("a".repeat(64)); + private static final String OWNER = "owner_token_1234567890"; + private static final String OPERATION = "operation_token_12345"; + private static final byte[] SECRET = "s".repeat(32).getBytes(US_ASCII); + + @Test + void emitsClaimConflictAndMutationIndeterminateWithoutScopeOrTokens() { + FakeCommands commands = new FakeCommands(); + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + AtomicLong ticker = new AtomicLong(); + RedisIdempotencyStoreProvider provider = + new RedisIdempotencyStoreProvider( + new RedisIdempotencyKeyFactory("worklog-api", "test", 1, 1, SECRET), + new RedisIdempotencyProgramExecutor(catalog, commands), + new RedisIdempotencyRecordCodec(), + new RedisIdempotencyTokenGenerator(new SecureRandom()), + observations, + () -> ticker.getAndAdd(10)); + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + attempt, + Duration.ofSeconds(30), + Duration.ofHours(1), + "json-v2", + "policy-v2"); + commands.reply = reply("FINGERPRINT_MISMATCH", "0", "0", "-", "-", "-"); + assertThat(provider.claim(request)) + .isInstanceOf(IdempotencyClaimOutcome.FingerprintMismatch.class); + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "lost response containing " + OWNER, + null); + assertThat( + provider + .complete( + new IdempotencyOwner(SCOPE, OWNER, 1), + new StoredResponse("secret-response"), + Duration.ofMinutes(5), + OPERATION) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.INDETERMINATE); + + assertThat(observations.operations()) + .extracting( + RedisCapabilityObservationEvent.OperationCompleted::operation, + RedisCapabilityObservationEvent.OperationCompleted::outcome, + RedisCapabilityObservationEvent.OperationCompleted::certainty) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_CLAIM, + RedisCapabilityObservationEvent.Outcome.CONFLICT, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.IDEMPOTENCY_COMPLETE, + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE)); + assertThat(observations.operations().toString()) + .doesNotContain(OWNER, OPERATION, "secret-response"); + } + + @Test + void mapsClaimStartCompleteAndInspectWithoutLeakingTheScope() { + FakeCommands commands = new FakeCommands(); + RedisIdempotencyStoreProvider provider = provider(commands); + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + attempt, + Duration.ofSeconds(30), + Duration.ofHours(1), + "json-v2", + "policy-v2"); + + commands.reply = reply("ACQUIRED", "1", "1785312030000", "-", "-", "-"); + IdempotencyClaimOutcome.Acquired acquired = + (IdempotencyClaimOutcome.Acquired) provider.claim(request); + assertThat(acquired.owner()).isEqualTo(new IdempotencyOwner(SCOPE, OWNER, 1)); + assertThat(new String(commands.key, US_ASCII)) + .doesNotContain("request-key") + .doesNotContain("principal-digest"); + assertThat(strings(commands.arguments)) + .containsExactly( + "2", FINGERPRINT.hex(), OWNER, OPERATION, "30000", "3600000", "json-v2", "policy-v2"); + + commands.reply = reply("STARTED", "1", "1785312030000", "-", "-", "-"); + assertThat(provider.markExecutionStarted(acquired.owner(), OPERATION).status()) + .isEqualTo(dev.caskeleton.application.idempotency.IdempotencyStartOutcome.Status.STARTED); + + commands.reply = reply("COMPLETED", "1", "1785315600000", "-", "-", OPERATION); + assertThat( + provider + .complete(acquired.owner(), new StoredResponse(""), Duration.ofHours(1), OPERATION) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.COMPLETED); + + commands.reply = + reply( + "COMPLETED_REPLAY", + "1", + "1785315600000", + "-", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "-"); + IdempotencyInspection.CompletedReplay replay = + (IdempotencyInspection.CompletedReplay) + provider.inspect(new IdempotencyInspectionRequest(SCOPE, FINGERPRINT, attempt)); + assertThat(replay.response()).isEqualTo(new StoredResponse("")); + assertThat(replay.replayUntil()).isEqualTo(Instant.ofEpochMilli(1785315600000L)); + } + + @Test + void separatesResponseConflictAndIndeterminateMutation() { + FakeCommands commands = new FakeCommands(); + RedisIdempotencyStoreProvider provider = provider(commands); + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 1); + + commands.reply = reply("RESPONSE_CONFLICT", "1", "0", "-", "-", "-"); + assertThat( + provider + .complete(owner, new StoredResponse("first"), Duration.ofHours(1), OPERATION) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.RESPONSE_CONFLICT); + + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "response lost", + null); + assertThat( + provider + .complete(owner, new StoredResponse("first"), Duration.ofHours(1), OPERATION) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.INDETERMINATE); + } + + @Test + void corruptedCompletedPayloadOrDigestFailsClosedAsUnavailable() { + FakeCommands commands = new FakeCommands(); + RedisIdempotencyStoreProvider provider = provider(commands); + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + attempt, + Duration.ofSeconds(30), + Duration.ofHours(1), + "json-v2", + "policy-v2"); + + commands.reply = + reply("COMPLETED_REPLAY", "1", "1785315600000", "Y3JlYXRlZA", "0".repeat(64), "-"); + assertThat(provider.claim(request)).isInstanceOf(IdempotencyClaimOutcome.Unavailable.class); + + commands.reply = reply("COMPLETED_REPLAY", "1", "1785315600000", "%%%", "0".repeat(64), "-"); + assertThat(provider.inspect(new IdempotencyInspectionRequest(SCOPE, FINGERPRINT, attempt))) + .isInstanceOf(IdempotencyInspection.Unavailable.class); + } + + @Test + void incompatibleMutationStateMapsToUnavailableInsteadOfEscapingEnumParsing() { + FakeCommands commands = new FakeCommands(); + RedisIdempotencyStoreProvider provider = provider(commands); + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 1); + + commands.reply = reply("STATE_INCOMPATIBLE", "0", "0", "-", "-", "-"); + + assertThat(provider.markExecutionStarted(owner, OPERATION).status()) + .isEqualTo( + dev.caskeleton.application.idempotency.IdempotencyStartOutcome.Status.UNAVAILABLE); + assertThat( + provider + .complete(owner, new StoredResponse("created"), Duration.ofHours(1), OPERATION) + .status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.UNAVAILABLE); + } + + @Test + void boundsRetryHintsAndFailsClosedOnSemanticallyMalformedReplies() { + FakeCommands commands = new FakeCommands(); + RedisIdempotencyStoreProvider provider = provider(commands); + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + attempt, + Duration.ofHours(24), + Duration.ofDays(2), + "json-v2", + "policy-v2"); + + commands.reply = reply("IN_PROGRESS", "1", "86400000", "-", "-", "-"); + IdempotencyClaimOutcome.InProgress inProgress = + (IdempotencyClaimOutcome.InProgress) provider.claim(request); + assertThat(inProgress.retryAfter()).isEqualTo(Duration.ofMinutes(5)); + + commands.reply = reply("ACQUIRED", "0", "1785312030000", "-", "-", "-"); + assertThat(provider.claim(request)).isInstanceOf(IdempotencyClaimOutcome.Unavailable.class); + } + + @Test + void closeDestroysHmacMaterialAndRejectsEveryNewOperation() { + FakeCommands commands = new FakeCommands(); + RedisIdempotencyStoreProvider provider = provider(commands); + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + attempt, + Duration.ofSeconds(30), + Duration.ofHours(1), + "json-v2", + "policy-v2"); + + provider.close(); + + assertThat(provider.destroyed()).isTrue(); + assertThatThrownBy(() -> provider.newClaimAttempt("closed_operation_token")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + assertThat(provider.claim(request)).isInstanceOf(IdempotencyClaimOutcome.Unavailable.class); + } + + private static RedisIdempotencyStoreProvider provider(FakeCommands commands) { + RedisProgramCatalog catalog = RedisProgramCatalog.idempotencyV2(); + return new RedisIdempotencyStoreProvider( + new RedisIdempotencyKeyFactory("worklog-api", "test", 1, 1, SECRET), + new RedisIdempotencyProgramExecutor(catalog, commands), + new RedisIdempotencyRecordCodec(), + new RedisIdempotencyTokenGenerator(new SecureRandom())); + } + + private static List reply(String... fields) { + return java.util.Arrays.stream(fields).map(field -> field.getBytes(US_ASCII)).toList(); + } + + private static List strings(List values) { + return values.stream().map(value -> new String(value, US_ASCII)).toList(); + } + + private static final class FakeCommands implements RedisStructuredCommands { + + private List reply = reply("ABSENT", "0", "0", "-", "-", "-"); + private RuntimeException failure; + private byte[] key; + private List arguments; + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + List keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + List actualArguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); + key = keys.getFirst(); + this.arguments = actualArguments; + if (failure != null) { + throw failure; + } + return RedisCatalogProgramReply.multi(reply); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + throw new AssertionError("unit fake should not require SCRIPT LOAD recovery"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramCatalogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramCatalogTest.java new file mode 100644 index 0000000..88798b5 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseProgramCatalogTest.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisLeaseProgramCatalogTest { + + @Test + void manifestPinsTheOwnerSafeEfficiencyOnlyContract() throws IOException { + String manifest; + try (InputStream input = + getClass().getClassLoader().getResourceAsStream("redis/lease-program-set.json")) { + assertThat(input).isNotNull(); + manifest = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(JsonPath.read(manifest, "$.minimumRedisVersion")).isEqualTo("7.2"); + assertThat(JsonPath.read(manifest, "$.readiness")).isEqualTo("CANDIDATE"); + assertThat(JsonPath.read(manifest, "$.guarantee")).isEqualTo("EFFICIENCY_ONLY"); + assertThat(JsonPath.read(manifest, "$.fencing")).isFalse(); + assertThat(JsonPath.read(manifest, "$.role")).isEqualTo("COORDINATION"); + + RedisProgramCatalog catalog = RedisProgramCatalog.efficiencyLease(); + List> programs = JsonPath.read(manifest, "$.programs"); + assertThat(programs).hasSameSizeAs(catalog.descriptors()); + programs.forEach( + program -> { + RedisProgramId id = + catalog.descriptors().stream() + .map(RedisProgramDescriptor::id) + .filter(candidate -> candidate.externalId().equals(program.get("id"))) + .findFirst() + .orElseThrow(); + RedisProgramDescriptor descriptor = catalog.descriptor(id); + assertThat(program.get("sha256")).isEqualTo(descriptor.sha256()); + assertThat(program.get("keyCount")).isEqualTo(descriptor.keyCount()); + assertThat(program.get("argumentCount")).isEqualTo(descriptor.argumentCount()); + assertThat(program.get("replyFieldCount")).isEqualTo(descriptor.replyFieldCount()); + assertThat(Set.copyOf((List) program.get("statuses"))) + .isEqualTo(descriptor.statuses()); + if (id == RedisProgramId.LEASE_INSPECT_V1) { + assertThat(descriptor.contract().retrySafety()).isEqualTo("READ_ONLY_RETRY_SAFE"); + } else { + assertThat(descriptor.contract().retrySafety()).isEqualTo("INSPECT_BY_OPERATION_ID"); + } + assertThat(descriptor.contract().clock()).isEqualTo("REDIS_SERVER_TIME"); + }); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettingsTest.java new file mode 100644 index 0000000..9938a50 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLeaseSettingsTest.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class RedisLeaseSettingsTest { + + @Test + void disabledSettingsNeedNoSecretAndKeepBoundedDefaults() { + RedisLeaseSettings settings = new RedisLeaseSettings(null, null, null, null, 0, 0, null); + + assertThat(settings.provider()).isEmpty(); + assertThat(settings.keyHmacSecretReference()).isEmpty(); + assertThat(settings.namespaceApplication()).isEqualTo("ca-skeleton"); + assertThat(settings.namespaceEnvironment()).isEqualTo("local"); + assertThat(settings.driftBudget()).isEqualTo(Duration.ofMillis(10)); + } + + @Test + void binderCompilesTheExplicitRedisEfficiencyLeasePolicy() { + RedisLeaseSettings settings = + new Binder( + new MapConfigurationPropertySource( + Map.of( + "ca-skeleton.capabilities.lease.provider", + "redis", + "ca-skeleton.capabilities.lease.key-hmac-secret-reference", + "secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.lease.namespace-application", + "worklog-api", + "ca-skeleton.capabilities.lease.namespace-environment", + "prod", + "ca-skeleton.capabilities.lease.drift-budget", + "25ms"))) + .bind("ca-skeleton.capabilities.lease", Bindable.of(RedisLeaseSettings.class)) + .orElseThrow(() -> new AssertionError("lease settings did not bind")); + + settings.validateActive(); + assertThat(settings.provider()).isEqualTo("redis"); + assertThat(settings.namespaceApplication()).isEqualTo("worklog-api"); + assertThat(settings.namespaceEnvironment()).isEqualTo("prod"); + assertThat(settings.driftBudget()).isEqualTo(Duration.ofMillis(25)); + } + + @Test + void activeSettingsRejectInlineOrMissingSecretAndUnboundedDrift() { + assertThatThrownBy( + () -> + new RedisLeaseSettings( + "redis", "inline-secret", "ca-skeleton", "test", 1, 1, Duration.ZERO) + .validateActive()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("secret"); + + assertThatThrownBy( + () -> + new RedisLeaseSettings( + "redis", + "secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET", + "ca-skeleton", + "test", + 1, + 1, + Duration.ofSeconds(6))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("driftBudget"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceConfigurationFactoryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceConfigurationFactoryTest.java new file mode 100644 index 0000000..442e7f4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceConfigurationFactoryTest.java @@ -0,0 +1,347 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisURI; +import io.lettuce.core.SslVerifyMode; +import io.lettuce.core.cluster.ClusterClientOptions; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisLettuceConfigurationFactoryTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + private static final String DATA_PASSWORD = "data-password"; + private static final String SENTINEL_PASSWORD = "sentinel-password"; + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "worklog-prod-cache", Duration.ofMillis(750), 31, 7, Duration.ofSeconds(23)); + + @Test + void createsAStandaloneDataUriWithAclTlsClientNameDatabaseAndFiniteTimeout() { + CapturingProvider provider = new CapturingProvider(); + RedisLettuceUriFactory factory = uriFactory(provider); + + RedisLettuceUris.Standalone result = + (RedisLettuceUris.Standalone) + factory.create( + new RedisDeploymentSettings.Standalone( + "cache-main", + 2, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6380)), + dataAuthentication(), + tls("secret://redis/data/ca")), + CLIENT_SETTINGS); + + RedisURI uri = result.dataUri(); + assertThat(uri.getHost()).isEqualTo("cache.internal"); + assertThat(uri.getPort()).isEqualTo(6380); + assertThat(uri.getDatabase()).isEqualTo(2); + assertThat(uri.getClientName()).isEqualTo("worklog-prod-cache"); + assertThat(uri.getTimeout()).isEqualTo(Duration.ofMillis(750)); + assertThat(username(uri)).isEqualTo("data-runtime"); + assertThat(password(uri)).isEqualTo(DATA_PASSWORD); + assertFullTls(uri); + assertThat(uri.toString()) + .doesNotContain(DATA_PASSWORD) + .doesNotContain("secret://redis/data/password"); + assertThat(provider.references).containsExactly("secret://redis/data/password"); + assertThat(provider.materials) + .allSatisfy(material -> assertThat(material.isDestroyed()).isTrue()); + result.close(); + } + + @Test + void createsSentinelDiscoveryUrisWithoutResolvingDataCredentials() { + CapturingProvider provider = new CapturingProvider(); + RedisLettuceUriFactory factory = uriFactory(provider); + + RedisLettuceUris.SentinelDiscovery result = + (RedisLettuceUris.SentinelDiscovery) + factory.create( + new RedisDeploymentSettings.Sentinel( + "coord-main", + 4, + "ca-coordination", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca"), + dataAuthentication(), + tls("secret://redis/data/ca")), + CLIENT_SETTINGS); + + assertThat(result.discoveryUris()).hasSize(3); + for (RedisURI discoveryUri : result.discoveryUris()) { + assertThat(username(discoveryUri)).isEqualTo("sentinel-runtime"); + assertThat(password(discoveryUri)).isEqualTo(SENTINEL_PASSWORD); + assertThat(discoveryUri.getTimeout()).isEqualTo(Duration.ofMillis(750)); + assertThat(discoveryUri.getClientName()).isEqualTo("worklog-prod-cache"); + assertFullTls(discoveryUri); + assertThat(discoveryUri.toString()) + .doesNotContain(SENTINEL_PASSWORD) + .doesNotContain("secret://redis/sentinel/password"); + } + assertThat(provider.references).containsExactly("secret://redis/sentinel/password"); + assertThat(provider.materials) + .allSatisfy(material -> assertThat(material.isDestroyed()).isTrue()); + result.close(); + } + + @Test + void createsAnExactSentinelDataUriWithoutResolvingDiscoveryCredentials() { + CapturingProvider provider = new CapturingProvider(); + + RedisLettuceUris.SentinelData result = + uriFactory(provider) + .createSentinelData( + sentinel(), + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379), + CLIENT_SETTINGS); + + RedisURI dataUri = result.dataUri(); + assertThat(dataUri.getHost()).isEqualTo("redis-primary.internal"); + assertThat(dataUri.getPort()).isEqualTo(6379); + assertThat(dataUri.getSentinelMasterId()).isNull(); + assertThat(dataUri.getSentinels()).isEmpty(); + assertThat(dataUri.getDatabase()).isEqualTo(4); + assertThat(dataUri.getClientName()).isEqualTo("worklog-prod-cache"); + assertThat(username(dataUri)).isEqualTo("data-runtime"); + assertThat(password(dataUri)).isEqualTo(DATA_PASSWORD); + assertFullTls(dataUri); + assertThat(provider.references).containsExactly("secret://redis/data/password"); + result.close(); + } + + @Test + void createsClusterSeedUrisWithDataAclAndFullTls() { + CapturingProvider provider = new CapturingProvider(); + RedisLettuceUriFactory factory = uriFactory(provider); + + RedisLettuceUris.Cluster result = + (RedisLettuceUris.Cluster) + factory.create( + new RedisDeploymentSettings.Cluster( + "cluster-main", + 0, + List.of( + new RedisDeploymentSettings.Endpoint("redis-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-b.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-c.internal", 6379)), + dataAuthentication(), + tls("secret://redis/data/ca")), + CLIENT_SETTINGS); + + assertThat(result.seedUris()).hasSize(3); + assertThat(result.seedUris()) + .extracting(RedisURI::getHost) + .containsExactly("redis-a.internal", "redis-b.internal", "redis-c.internal"); + for (RedisURI uri : result.seedUris()) { + assertThat(uri.getDatabase()).isZero(); + assertThat(username(uri)).isEqualTo("data-runtime"); + assertThat(password(uri)).isEqualTo(DATA_PASSWORD); + assertFullTls(uri); + } + assertThat(provider.references).containsExactly("secret://redis/data/password"); + result.close(); + } + + @Test + void rejectsPlaintextOrNonVerifyingTlsAndExpiredCredentialMaterial() { + RedisDeploymentSettings.Standalone plaintext = + new RedisDeploymentSettings.Standalone( + "cache-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6379)), + dataAuthentication(), + new RedisDeploymentSettings.Tls(false, false, "")); + + assertThatThrownBy(() -> uriFactory(new CapturingProvider()).create(plaintext, CLIENT_SETTINGS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TLS") + .hasMessageContaining("FULL"); + + RedisCredentialMaterialProvider expiredProvider = + ignored -> + new VersionedRedisCredentialMaterial( + "expired-v1", + NOW.minusSeconds(1), + DestroyableRedisSecret.from(DATA_PASSWORD.toCharArray())); + RedisDeploymentSettings.Standalone secure = + new RedisDeploymentSettings.Standalone( + "cache-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6379)), + dataAuthentication(), + tls("secret://redis/data/ca")); + + assertThatThrownBy(() -> uriFactory(expiredProvider).create(secure, CLIENT_SETTINGS)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expired") + .hasMessageNotContaining(DATA_PASSWORD); + } + + @Test + void sanitizesCredentialProviderFailures() { + RedisCredentialMaterialProvider leakingProvider = + ignored -> { + throw new IllegalStateException("plain-data-password at secret://redis/data/password"); + }; + + assertThatThrownBy(() -> uriFactory(leakingProvider).create(standalone(), CLIENT_SETTINGS)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("resolution failed") + .hasMessageNotContaining("plain-data-password") + .hasMessageNotContaining("secret://redis/data/password") + .hasNoCause(); + } + + @Test + void createsBoundedNoReplayStandaloneAndClusterClientOptions() { + RedisLettuceClientOptionsFactory factory = new RedisLettuceClientOptionsFactory(); + + ClientOptions standalone = factory.clientOptions(CLIENT_SETTINGS); + assertThat(standalone.isAutoReconnect()).isTrue(); + assertThat(standalone.getReplayFilter().test(null)).isTrue(); + assertThat(standalone.getDisconnectedBehavior()) + .isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS); + assertThat(standalone.getRequestQueueSize()).isEqualTo(31); + assertThat(standalone.getTimeoutOptions().isTimeoutCommands()).isTrue(); + + ClusterClientOptions cluster = factory.clusterClientOptions(CLIENT_SETTINGS); + assertThat(cluster.getReplayFilter().test(null)).isTrue(); + assertThat(cluster.getDisconnectedBehavior()) + .isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS); + assertThat(cluster.getRequestQueueSize()).isEqualTo(31); + assertThat(cluster.getMaxRedirects()).isEqualTo(7); + assertThat(cluster.getTopologyRefreshOptions().isPeriodicRefreshEnabled()).isTrue(); + assertThat(cluster.getTopologyRefreshOptions().getRefreshPeriod()) + .isEqualTo(Duration.ofSeconds(23)); + assertThat(cluster.getTopologyRefreshOptions().getAdaptiveRefreshTriggers()).isNotEmpty(); + } + + @Test + void rejectsUnboundedOrInvalidClientRuntimeSettings() { + assertThatThrownBy( + () -> + new RedisClientRuntimeSettings( + "cache-client", Duration.ZERO, 31, 7, Duration.ofSeconds(23))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("timeout"); + assertThatThrownBy( + () -> + new RedisClientRuntimeSettings( + "cache-client", Duration.ofSeconds(1), 0, 7, Duration.ofSeconds(23))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("queue"); + assertThatThrownBy( + () -> + new RedisClientRuntimeSettings( + "cache-client", Duration.ofSeconds(1), 31, 0, Duration.ofSeconds(23))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("redirect"); + } + + private static RedisLettuceUriFactory uriFactory( + RedisCredentialMaterialProvider materialProvider) { + return new RedisLettuceUriFactory(materialProvider, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static RedisDeploymentSettings.Authentication dataAuthentication() { + return new RedisDeploymentSettings.Authentication( + "data-runtime", "secret://redis/data/password"); + } + + private static RedisDeploymentSettings.Standalone standalone() { + return new RedisDeploymentSettings.Standalone( + "cache-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6379)), + dataAuthentication(), + tls("secret://redis/data/ca")); + } + + private static RedisDeploymentSettings.Sentinel sentinel() { + return new RedisDeploymentSettings.Sentinel( + "coord-main", + 4, + "ca-coordination", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca"), + dataAuthentication(), + tls("secret://redis/data/ca")); + } + + private static RedisDeploymentSettings.Tls tls(String trustBundleReference) { + return new RedisDeploymentSettings.Tls(true, true, trustBundleReference); + } + + private static void assertFullTls(RedisURI uri) { + assertThat(uri.isSsl()).isTrue(); + assertThat(uri.getVerifyMode()).isEqualTo(SslVerifyMode.FULL); + } + + private static String password(RedisURI uri) { + io.lettuce.core.RedisCredentialsProvider provider = uri.getCredentialsProvider(); + return new String( + ((io.lettuce.core.RedisCredentialsProvider.ImmediateRedisCredentialsProvider) provider) + .resolveCredentialsNow() + .getPassword()); + } + + private static String username(RedisURI uri) { + io.lettuce.core.RedisCredentialsProvider provider = uri.getCredentialsProvider(); + return ((io.lettuce.core.RedisCredentialsProvider.ImmediateRedisCredentialsProvider) provider) + .resolveCredentialsNow() + .getUsername(); + } + + private static final class CapturingProvider implements RedisCredentialMaterialProvider { + + private final List references = new ArrayList<>(); + private final List materials = new ArrayList<>(); + + @Override + public VersionedRedisCredentialMaterial resolve( + dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference reference) { + references.add(reference.valueForResolution()); + String password = + reference.valueForResolution().contains("sentinel") ? SENTINEL_PASSWORD : DATA_PASSWORD; + VersionedRedisCredentialMaterial material = + new VersionedRedisCredentialMaterial( + "credential-v1", + NOW.plus(Duration.ofHours(1)), + DestroyableRedisSecret.from(password.toCharArray())); + materials.add(material); + return material; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUrisTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUrisTest.java new file mode 100644 index 0000000..f77592a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLettuceUrisTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.RedisURI; +import java.lang.reflect.Modifier; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisLettuceUrisTest { + + @Test + void keepsLettuceNativeUriOwnershipPackagePrivate() { + assertThat(Modifier.isPublic(RedisLettuceUris.class.getModifiers())).isFalse(); + } + + @Test + void sentinelDiscoveryCloseDestroysItsSharedCredentialOwnerExactlyOnce() { + CountingCredentialsProvider discovery = new CountingCredentialsProvider(); + RedisLettuceUris.SentinelDiscovery uris = + new RedisLettuceUris.SentinelDiscovery( + List.of( + RedisURI.builder() + .withHost("sentinel-a.internal") + .withPort(26379) + .withAuthentication(discovery) + .build(), + RedisURI.builder() + .withHost("sentinel-b.internal") + .withPort(26379) + .withAuthentication(discovery) + .build())); + + uris.close(); + uris.close(); + + assertThat(discovery.destroyCalls).hasValue(1); + } + + @Test + void sentinelDataCloseDestroysItsCredentialOwnerExactlyOnce() { + CountingCredentialsProvider data = new CountingCredentialsProvider(); + RedisLettuceUris.SentinelData uris = + new RedisLettuceUris.SentinelData( + RedisURI.builder() + .withHost("redis-primary.internal") + .withPort(6379) + .withAuthentication(data) + .build()); + + uris.close(); + uris.close(); + + assertThat(data.destroyCalls).hasValue(1); + } + + private static final class CountingCredentialsProvider + implements io.lettuce.core.RedisCredentialsProvider, + io.lettuce.core.RedisCredentialsProvider.ImmediateRedisCredentialsProvider, + javax.security.auth.Destroyable { + + private final java.util.concurrent.atomic.AtomicInteger destroyCalls = + new java.util.concurrent.atomic.AtomicInteger(); + + @Override + public reactor.core.publisher.Mono resolveCredentials() { + return reactor.core.publisher.Mono.error(new UnsupportedOperationException()); + } + + @Override + public io.lettuce.core.RedisCredentials resolveCredentialsNow() { + throw new UnsupportedOperationException(); + } + + @Override + public void destroy() { + destroyCalls.incrementAndGet(); + } + + @Override + public boolean isDestroyed() { + return destroyCalls.get() > 0; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLifecycleTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLifecycleTest.java new file mode 100644 index 0000000..1bd3920 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLifecycleTest.java @@ -0,0 +1,194 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RedisLifecycleTest { + + @Test + void admittedCommandDrainsAfterBarrierBeforeRuntimeCloses() throws Exception { + runScenario(RedisDrainWaiter.Result.DRAINED); + } + + @Test + void admittedCommandIsForcedClosedAfterDeterministicTimeout() throws Exception { + runScenario(RedisDrainWaiter.Result.TIMED_OUT); + } + + @Test + void admittedCommandIsForcedClosedAfterDeterministicInterruption() throws Exception { + runScenario(RedisDrainWaiter.Result.INTERRUPTED); + } + + private static void runScenario(RedisDrainWaiter.Result expectedResult) throws Exception { + BlockingRuntime runtime = new BlockingRuntime(); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + CountDownLatch waiterEntered = new CountDownLatch(1); + CountDownLatch releaseWaiter = new CountDownLatch(1); + AtomicReference closer = new AtomicReference<>(); + AtomicBoolean interruptedAfterClose = new AtomicBoolean(); + RedisDrainWaiter system = RedisDrainWaiter.system(); + RedisDrainWaiter waiter = + (inFlight, monitor, timeout) -> { + assertThat(inFlight.getAsInt()).isEqualTo(1); + waiterEntered.countDown(); + if (expectedResult == RedisDrainWaiter.Result.TIMED_OUT) { + await(releaseWaiter); + assertThat(inFlight.getAsInt()).isEqualTo(1); + return RedisDrainWaiter.Result.TIMED_OUT; + } + return system.await(inFlight, monitor, timeout); + }; + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + RedisRole.SESSION, + runtime, + 1, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + waiter); + + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + Future> admitted = executor.submit(() -> router.read("admitted")); + assertThat(runtime.commandEntered.await(5, TimeUnit.SECONDS)).isTrue(); + Future closing = + executor.submit( + () -> { + closer.set(Thread.currentThread()); + router.close(); + interruptedAfterClose.set(Thread.currentThread().isInterrupted()); + }); + assertThat(waiterEntered.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("late"))) + .isInstanceOf(IllegalStateException.class); + assertThat(runtime.sends).hasValue(1); + + switch (expectedResult) { + case DRAINED -> { + runtime.releaseCommand.countDown(); + assertThat(admitted.get(5, TimeUnit.SECONDS)).contains("value"); + } + case TIMED_OUT -> releaseWaiter.countDown(); + case INTERRUPTED -> closer.get().interrupt(); + default -> throw new AssertionError("Unhandled drain result: " + expectedResult); + } + + closing.get(5, TimeUnit.SECONDS); + if (expectedResult != RedisDrainWaiter.Result.DRAINED) { + assertThat(admitted.isDone()).isFalse(); + runtime.releaseCommand.countDown(); + assertThat(admitted.get(5, TimeUnit.SECONDS)).contains("value"); + } + } + + RedisCapabilityObservationEvent.DrainOutcome expectedOutcome = + switch (expectedResult) { + case DRAINED -> RedisCapabilityObservationEvent.DrainOutcome.DRAINED; + case TIMED_OUT -> RedisCapabilityObservationEvent.DrainOutcome.FORCED_AFTER_TIMEOUT; + case INTERRUPTED -> RedisCapabilityObservationEvent.DrainOutcome.INTERRUPTED; + }; + assertThat(observations.events()) + .filteredOn(RedisCapabilityObservationEvent.LifecycleDrainCompleted.class::isInstance) + .containsExactly( + new RedisCapabilityObservationEvent.LifecycleDrainCompleted( + RedisCapabilityObservationEvent.Role.SESSION, expectedOutcome)); + assertThat(runtime.closes).hasValue(1); + assertThat(runtime.closedAfterBarrier).isTrue(); + assertThat(runtime.sends).hasValue(1); + assertThat(interruptedAfterClose.get()) + .isEqualTo(expectedResult == RedisDrainWaiter.Result.INTERRUPTED); + + router.close(); + assertThat(runtime.closes).hasValue(1); + assertThat(observations.events()) + .filteredOn(RedisCapabilityObservationEvent.LifecycleDrainCompleted.class::isInstance) + .hasSize(1); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("lifecycle latch timed out"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("lifecycle latch interrupted", exception); + } + } + + private static final class BlockingRuntime implements RedisRoutableCommandRuntime { + + private final CountDownLatch commandEntered = new CountDownLatch(1); + private final CountDownLatch releaseCommand = new CountDownLatch(1); + private final AtomicInteger sends = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + private volatile boolean closedAfterBarrier; + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return "lifecycle"; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + sends.incrementAndGet(); + commandEntered.countDown(); + await(releaseCommand); + return "value".getBytes(StandardCharsets.UTF_8); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return RedisCatalogProgramReply.value(null); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 0; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + return () -> {}; + } + + @Override + public void close() { + closes.incrementAndGet(); + closedAfterBarrier = true; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicyTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicyTest.java new file mode 100644 index 0000000..59c65e0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCachePolicyTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisLocalCachePolicyTest { + + @Test + void acceptsFiniteCardinalityWeightTtlReconciliationAndQueueBounds() { + RedisLocalCachePolicy policy = + new RedisLocalCachePolicy( + 100, 1_048_576, 65_536, Duration.ofSeconds(30), Duration.ofSeconds(5), 128); + + assertThat(policy.maximumEntries()).isEqualTo(100); + assertThat(policy.maximumWeightBytes()).isEqualTo(1_048_576); + assertThat(policy.maximumEntryWeightBytes()).isEqualTo(65_536); + assertThat(policy.localTimeToLive()).isEqualTo(Duration.ofSeconds(30)); + assertThat(policy.generationRecheckInterval()).isEqualTo(Duration.ofSeconds(5)); + assertThat(policy.invalidationQueueCapacity()).isEqualTo(128); + } + + @Test + void rejectsUnboundedOrInternallyInconsistentPolicies() { + assertThatThrownBy( + () -> + new RedisLocalCachePolicy( + 0, 1_048_576, 65_536, Duration.ofSeconds(30), Duration.ofSeconds(5), 128)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumEntries"); + + assertThatThrownBy( + () -> + new RedisLocalCachePolicy( + 100, 1024, 2048, Duration.ofSeconds(30), Duration.ofSeconds(5), 128)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumEntryWeightBytes"); + + assertThatThrownBy( + () -> + new RedisLocalCachePolicy( + 100, 1_048_576, 65_536, Duration.ofSeconds(30), Duration.ofSeconds(31), 128)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("generationRecheckInterval"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegionTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegionTest.java new file mode 100644 index 0000000..907ad12 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheRegionTest.java @@ -0,0 +1,386 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheInvalidationOutcome; +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheObservationEvent; +import dev.caskeleton.application.cache.CacheObservationPort; +import dev.caskeleton.application.cache.CacheObservationToken; +import dev.caskeleton.application.cache.CacheRecordMetadata; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.cache.CacheWriteCondition; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RedisLocalCacheRegionTest { + + private static final Instant START = Instant.parse("2026-07-29T00:00:00Z"); + private static final byte[] SECRET = + "01234567890123456789012345678901".getBytes(StandardCharsets.US_ASCII); + private static final RedisLocalCachePolicy POLICY = + new RedisLocalCachePolicy(2, 512, 256, Duration.ofSeconds(10), Duration.ofSeconds(2), 2); + + private final MutableClock clock = new MutableClock(START); + private final FakeL2Region l2 = new FakeL2Region(); + private final RecordingObservations observations = new RecordingObservations(); + private final List published = new ArrayList<>(); + private RedisLocalCacheRegion region; + + @BeforeEach + void setUp() { + region = + new RedisLocalCacheRegion( + "worklog", + l2, + POLICY, + clock, + observations, + "cache-invalidation-channel", + new RedisCacheInvalidationMessage.Codec(SECRET), + published::add); + } + + @Test + void localHitAvoidsL2AndNeverOutlivesTheL2HardExpiry() { + l2.put("first", hit("value-1", START.plusSeconds(4))); + + assertThat(region.lookup("first")).isInstanceOf(CacheLookup.Hit.class); + assertThat(region.lookup("first")).isInstanceOf(CacheLookup.Hit.class); + assertThat(l2.lookupCount).isEqualTo(1); + + clock.advance(Duration.ofSeconds(4)); + + assertThat(region.lookup("first")).isInstanceOf(CacheLookup.Hit.class); + assertThat(l2.lookupCount).isEqualTo(2); + } + + @Test + void localTierBoundsCardinalityAndWeightAndDoesNotCacheOversizedValues() { + l2.put("one", hit("1".repeat(20), START.plusSeconds(30))); + l2.put("two", hit("2".repeat(20), START.plusSeconds(30))); + l2.put("three", hit("3".repeat(20), START.plusSeconds(30))); + l2.put("large", hit("x".repeat(300), START.plusSeconds(30))); + + region.lookup("one"); + region.lookup("two"); + region.lookup("three"); + + assertThat(region.localEntryCount()).isEqualTo(2); + region.lookup("one"); + assertThat(l2.lookupCount).isEqualTo(4); + + region.lookup("large"); + region.lookup("large"); + + assertThat(l2.lookupCount).isEqualTo(6); + assertThat(region.localWeightBytes()).isLessThanOrEqualTo(POLICY.maximumWeightBytes()); + } + + @Test + void generationChangeFlushesLocalEntriesBeforeTheyCanBeServed() { + l2.put("key", hit("old", START.plusSeconds(30))); + region.lookup("key"); + assertThat(region.lookup("key")).isInstanceOf(CacheLookup.Hit.class); + + l2.generation = "generation-bbbbbbbbb"; + l2.put("key", hit("new", START.plusSeconds(30))); + clock.advance(POLICY.generationRecheckInterval()); + + CacheLookup.Hit lookup = (CacheLookup.Hit) region.lookup("key"); + + assertThat(lookup.value()).isEqualTo("new"); + assertThat(l2.lookupCount).isEqualTo(2); + assertThat(observations.maintenanceCauses()) + .contains(CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED); + } + + @Test + void lostPubSubHintIsStillBoundedByLocalTtl() { + l2.put("key", hit("old", START.plusSeconds(30))); + region.lookup("key"); + l2.put("key", hit("new", START.plusSeconds(30))); + + clock.advance(POLICY.localTimeToLive()); + + CacheLookup.Hit lookup = (CacheLookup.Hit) region.lookup("key"); + assertThat(lookup.value()).isEqualTo("new"); + assertThat(l2.lookupCount).isEqualTo(2); + } + + @Test + void disconnectFlushesAndRequiresGenerationRecheckBeforeRepopulation() { + l2.put("key", hit("old", START.plusSeconds(30))); + region.lookup("key"); + int probesBeforeDisconnect = l2.generationProbeCount; + + l2.generation = "generation-bbbbbbbbb"; + l2.put("key", hit("new", START.plusSeconds(30))); + region.invalidationSubscriber().onDisconnected(); + + CacheLookup.Hit lookup = (CacheLookup.Hit) region.lookup("key"); + + assertThat(lookup.value()).isEqualTo("new"); + assertThat(l2.generationProbeCount).isGreaterThan(probesBeforeDisconnect); + assertThat(observations.maintenanceCauses()) + .contains(CacheObservationEvent.MaintenanceCause.SUBSCRIBER_DISCONNECTED); + } + + @Test + void boundedSubscriberQueueOverflowFlushesAndForcesReconciliation() { + l2.put("one", hit("one", START.plusSeconds(30))); + region.lookup("one"); + + RedisCacheInvalidationSubscriber subscriber = region.invalidationSubscriber(); + subscriber.onMessage(RedisCacheInvalidationMessage.key("identity-one")); + subscriber.onMessage(RedisCacheInvalidationMessage.key("identity-two")); + subscriber.onMessage(RedisCacheInvalidationMessage.key("identity-three")); + + assertThat(subscriber.queuedHintCount()) + .isLessThanOrEqualTo(POLICY.invalidationQueueCapacity()); + assertThat(region.localEntryCount()).isZero(); + assertThat(observations.maintenanceCauses()) + .contains(CacheObservationEvent.MaintenanceCause.SUBSCRIBER_OVERFLOW); + } + + @Test + void validKeyHintEvictsOnlyTheMatchingHmacIdentity() { + l2.put("one", hit("one", START.plusSeconds(30))); + l2.put("two", hit("two", START.plusSeconds(30))); + region.lookup("one"); + region.lookup("two"); + + region + .invalidationSubscriber() + .onMessage(RedisCacheInvalidationMessage.key(l2.localEntryIdentity("one"))); + region.lookup("two"); + region.lookup("one"); + + assertThat(l2.lookupCount).isEqualTo(3); + } + + @Test + void cacheMutationsPublishOnlySignedOpaqueInvalidationMessages() { + l2.put("customer-email@example.com", hit("value", START.plusSeconds(30))); + region.lookup("customer-email@example.com"); + + CacheInvalidationOutcome outcome = region.invalidate("customer-email@example.com"); + + assertThat(outcome).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + assertThat(published).hasSize(1); + assertThat(published.getFirst()).doesNotContain("customer-email@example.com").startsWith("v1."); + } + + @Test + void disconnectDuringGenerationProbeInvalidatesTheProbePermitAndPreventsStaleAdmission() + throws Exception { + l2.put("key", hit("old", START.plusSeconds(30))); + region.lookup("key"); + clock.advance(POLICY.generationRecheckInterval()); + l2.blockNextGenerationProbe(); + + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future> lookup = executor.submit(() -> region.lookup("key")); + assertThat(l2.awaitBlockedProbe()).isTrue(); + + l2.generation = "generation-bbbbbbbbb"; + l2.put("key", hit("new", START.plusSeconds(30))); + region.invalidationSubscriber().onDisconnected(); + l2.releaseBlockedProbe(); + + assertThat(((CacheLookup.Hit) get(lookup)).value()).isEqualTo("new"); + assertThat(region.localEntryCount()).isZero(); + + assertThat(((CacheLookup.Hit) region.lookup("key")).value()).isEqualTo("new"); + assertThat(region.localEntryCount()).isEqualTo(1); + } + } + + @Test + void concurrentLookupCannotStartAnOutOfOrderSecondGenerationProbe() throws Exception { + l2.put("key", hit("value", START.plusSeconds(30))); + region.lookup("key"); + clock.advance(POLICY.generationRecheckInterval()); + int probesBefore = l2.generationProbeCount; + l2.blockNextGenerationProbe(); + + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future> first = executor.submit(() -> region.lookup("key")); + assertThat(l2.awaitBlockedProbe()).isTrue(); + Future> second = executor.submit(() -> region.lookup("key")); + + assertThat(get(second)).isInstanceOf(CacheLookup.Hit.class); + assertThat(l2.generationProbeCount).isEqualTo(probesBefore + 1); + + l2.releaseBlockedProbe(); + assertThat(get(first)).isInstanceOf(CacheLookup.Hit.class); + assertThat(l2.generationProbeCount).isEqualTo(probesBefore + 1); + } + } + + @Test + void conservativeWeightIncludesIdentityValueAndFixedEntryMetadataAllowance() { + assertThat(RedisLocalCacheRegion.conservativeEntryWeightBytes("hmac-key", "value")) + .isEqualTo(128 + "hmac-key".getBytes(StandardCharsets.UTF_8).length + 5); + } + + private static CacheLookup get(Future> future) + throws InterruptedException, ExecutionException { + return future.get(); + } + + private CacheLookup.Hit hit(String value, Instant hardExpiresAt) { + return new CacheLookup.Hit<>( + value, + CacheLookup.Freshness.FRESH, + "source-r1", + START.plusSeconds(1), + hardExpiresAt, + CacheObservationToken.unavailable(), + new CacheWriteCondition("v1.generation-aaaaaaaaa.revision-aaaaaaaaaaa")); + } + + private static final class FakeL2Region implements RedisCacheL2Region { + + private final Map> lookups = new HashMap<>(); + private String generation = "generation-aaaaaaaaa"; + private int lookupCount; + private int generationProbeCount; + private volatile boolean blockNextProbe; + private volatile CountDownLatch blockedProbeStarted = new CountDownLatch(0); + private volatile CountDownLatch blockedProbeRelease = new CountDownLatch(0); + + void put(String key, CacheLookup lookup) { + lookups.put(key, lookup); + } + + @Override + public CacheLookup lookup(String key) { + lookupCount++; + return lookups.getOrDefault(key, new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + } + + @Override + public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) { + return CacheRecordOutcome.RECORDED; + } + + @Override + public CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + return CacheRecordOutcome.RECORDED; + } + + @Override + public CacheInvalidationOutcome invalidate(String key) { + lookups.remove(key); + return CacheInvalidationOutcome.INVALIDATED; + } + + @Override + public CacheInvalidationOutcome invalidateRegion() { + lookups.clear(); + return CacheInvalidationOutcome.INVALIDATED; + } + + @Override + public String localEntryIdentity(String key) { + return "hmac-" + key; + } + + @Override + public String currentRegionGeneration() { + generationProbeCount++; + String observed = generation; + if (blockNextProbe) { + blockNextProbe = false; + blockedProbeStarted.countDown(); + try { + if (!blockedProbeRelease.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("blocked generation probe timed out"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("blocked generation probe interrupted", exception); + } + } + return observed; + } + + void blockNextGenerationProbe() { + blockedProbeStarted = new CountDownLatch(1); + blockedProbeRelease = new CountDownLatch(1); + blockNextProbe = true; + } + + boolean awaitBlockedProbe() throws InterruptedException { + return blockedProbeStarted.await(5, TimeUnit.SECONDS); + } + + void releaseBlockedProbe() { + blockedProbeRelease.countDown(); + } + } + + private static final class RecordingObservations implements CacheObservationPort { + + private final List events = new ArrayList<>(); + + @Override + public void observe(CacheObservationEvent event) { + events.add(event); + } + + List maintenanceCauses() { + return events.stream() + .filter(CacheObservationEvent.LocalMaintenance.class::isInstance) + .map(CacheObservationEvent.LocalMaintenance.class::cast) + .map(CacheObservationEvent.LocalMaintenance::cause) + .toList(); + } + } + + private static final class MutableClock extends Clock { + + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettingsTest.java new file mode 100644 index 0000000..dab4f99 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLocalCacheSettingsTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisLocalCacheSettingsTest { + + @Test + void defaultsToDisabledFiniteCacheOnlyBounds() { + RedisLocalCacheSettings settings = new RedisLocalCacheSettings(false, 0, 0, 0, null, null, 0); + + assertThat(settings.enabled()).isFalse(); + assertThat(settings.maximumEntries()).isEqualTo(10_000); + assertThat(settings.maximumWeightBytes()).isEqualTo(67_108_864); + assertThat(settings.maximumEntryWeightBytes()).isEqualTo(1_048_576); + assertThat(settings.timeToLive()).isEqualTo(Duration.ofSeconds(30)); + assertThat(settings.generationRecheckInterval()).isEqualTo(Duration.ofSeconds(5)); + assertThat(settings.invalidationQueueCapacity()).isEqualTo(1024); + } + + @Test + void rejectsARecheckIntervalLongerThanTheLocalTtl() { + assertThatThrownBy( + () -> + new RedisLocalCacheSettings( + true, + 100, + 1_048_576, + 65_536, + Duration.ofSeconds(5), + Duration.ofSeconds(6), + 128)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("generationRecheckInterval"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java index 126f90c..890c67b 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaProgramExecutorTest.java @@ -12,20 +12,19 @@ import org.junit.jupiter.api.Test; class RedisLuaProgramExecutorTest { @Test - void fallsBackToEvalOnlyWhenEvalShaReportsNoScript() { + void recoversNoScriptWithOneExactScriptLoadAndOneEvalShaRetry() { FakeCommands commands = new FakeCommands(); commands.noScript = true; RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog); - String status = - executor.execute( - descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8))); + String status = executor.execute(invocation(catalog, descriptor)); assertThat(status).isEqualTo("DELETED"); - assertThat(commands.evalShaCalls).hasValue(1); - assertThat(commands.evalCalls).hasValue(1); + assertThat(commands.evalShaCalls).hasValue(2); + assertThat(commands.scriptLoadCalls).hasValue(1); + assertThat(commands.commandTrace).containsExactly("EVALSHA", "SCRIPT_LOAD", "EVALSHA"); } @Test @@ -35,10 +34,10 @@ class RedisLuaProgramExecutorTest { RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); RedisProgramDescriptor descriptor = singleArgumentDescriptor(catalog); - executor.execute(descriptor, List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8))); + executor.execute(invocation(catalog, descriptor)); assertThat(commands.evalShaCalls).hasValue(1); - assertThat(commands.evalCalls).hasValue(0); + assertThat(commands.scriptLoadCalls).hasValue(0); } @Test @@ -50,15 +49,27 @@ class RedisLuaProgramExecutorTest { singleArgumentDescriptor(RedisProgramCatalog.foundation()); assertThatThrownBy( - () -> - executor.execute( - foreignDescriptor, - List.of("key".getBytes(UTF_8)), - List.of("owner".getBytes(UTF_8)))) + () -> executor.execute(invocation(RedisProgramCatalog.foundation(), foreignDescriptor))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("not owned"); assertThat(commands.evalShaCalls).hasValue(0); - assertThat(commands.evalCalls).hasValue(0); + assertThat(commands.scriptLoadCalls).hasValue(0); + } + + @Test + void rejectsAnUnexpectedScriptLoadDigestWithoutRetrying() { + FakeCommands commands = new FakeCommands(); + commands.noScript = true; + commands.loadedSha1 = "0000000000000000000000000000000000000000"; + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); + + assertThatThrownBy( + () -> executor.execute(invocation(catalog, singleArgumentDescriptor(catalog)))) + .isInstanceOf(RedisProgramCompatibilityException.class) + .hasMessageContaining("script-load-digest-mismatch"); + assertThat(commands.evalShaCalls).hasValue(1); + assertThat(commands.scriptLoadCalls).hasValue(1); } @Test @@ -69,55 +80,60 @@ class RedisLuaProgramExecutorTest { RedisLuaProgramExecutor executor = new RedisLuaProgramExecutor(catalog, commands); assertThatThrownBy( - () -> - executor.execute( - singleArgumentDescriptor(catalog), - List.of("key".getBytes(UTF_8)), - List.of("owner".getBytes(UTF_8)))) + () -> executor.execute(invocation(catalog, singleArgumentDescriptor(catalog)))) .isInstanceOf(RedisProgramCompatibilityException.class) .hasMessageContaining("UNDECLARED"); } private static RedisProgramDescriptor singleArgumentDescriptor(RedisProgramCatalog catalog) { - return catalog.descriptors().stream() - .filter(descriptor -> descriptor.keyCount() == 1 && descriptor.argumentCount() == 1) - .findFirst() - .orElseThrow(); + return catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE); + } + + private static RedisCatalogProgramInvocation invocation( + RedisProgramCatalog catalog, RedisProgramDescriptor descriptor) { + return RedisProgramTestInvocations.scalar( + catalog, descriptor.id(), List.of("key".getBytes(UTF_8)), List.of("owner".getBytes(UTF_8))); } private static final class FakeCommands implements RedisBinaryCommands { private final AtomicInteger evalShaCalls = new AtomicInteger(); - private final AtomicInteger evalCalls = new AtomicInteger(); + private final AtomicInteger scriptLoadCalls = new AtomicInteger(); + private final java.util.ArrayList commandTrace = new java.util.ArrayList<>(); private boolean noScript; private String result = "DELETED"; + private String loadedSha1; @Override - public byte[] get(byte[] key) { + public byte[] get(RedisPhysicalKey key) { return null; } @Override - public void set(byte[] key, byte[] value, Duration timeToLive) {} + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} @Override - public long delete(byte[] key) { + public long delete(RedisPhysicalKey key) { return 0; } @Override - public byte[] evalSha(String sha1, List keys, List arguments) { + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { evalShaCalls.incrementAndGet(); + commandTrace.add("EVALSHA"); if (noScript) { + noScript = false; throw new RedisNoScriptException(); } - return result.getBytes(UTF_8); + return RedisCatalogProgramReply.value(result.getBytes(UTF_8)); } @Override - public byte[] eval(byte[] script, List keys, List arguments) { - evalCalls.incrementAndGet(); - return result.getBytes(UTF_8); + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + scriptLoadCalls.incrementAndGet(); + commandTrace.add("SCRIPT_LOAD"); + return loadedSha1 == null ? invocation.sha1() : loadedSha1; } } } diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStoreTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStoreTest.java new file mode 100644 index 0000000..e95683b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisLuaVersionedSessionStoreTest.java @@ -0,0 +1,226 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisLuaVersionedSessionStoreTest { + + private static final byte[] SECRET = + "session-key-material-requires-at-least-32-bytes".getBytes(StandardCharsets.UTF_8); + + @Test + void emitsSessionCreateAndIndeterminateRevokeWithoutSessionOrOperationIdentity() { + RecordingCommands commands = new RecordingCommands(); + commands.replies.add(ascii("CREATED")); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + AtomicLong ticker = new AtomicLong(); + RedisLuaVersionedSessionStore store = + new RedisLuaVersionedSessionStore( + commands, "service", "test", 1, 1, SECRET, observations, () -> ticker.getAndAdd(10)); + SessionMutationAttempt attempt = new SessionMutationAttempt("operation-123"); + assertThat( + store.create( + new SessionCreateCommand( + "opaque-session-id-12345", + bytes("secret-envelope"), + 1, + Instant.parse("2026-07-29T08:00:00Z"), + Instant.parse("2026-07-29T07:00:00Z"), + Duration.ofMinutes(30), + attempt))) + .isEqualTo(SessionCreateOutcome.CREATED); + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "response lost opaque-session-id-12345", + null); + assertThat( + store.tombstoneAndDelete( + new SessionRevokeCommand( + "opaque-session-id-12345", 1, Duration.ofMinutes(5), attempt))) + .isEqualTo(SessionRevokeOutcome.INDETERMINATE); + + assertThat(observations.operations()) + .extracting( + RedisCapabilityObservationEvent.OperationCompleted::operation, + RedisCapabilityObservationEvent.OperationCompleted::outcome, + RedisCapabilityObservationEvent.OperationCompleted::certainty) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.SESSION_CREATE, + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.SESSION_REVOKE, + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE)); + assertThat(observations.operations().toString()) + .doesNotContain("opaque-session", "operation-123", "secret-envelope"); + } + + @Test + void createsWithPseudonymousSameSlotKeysAndRecoversOnlyTheClosedScript() { + RecordingCommands commands = new RecordingCommands(); + commands.noScriptOnce = true; + commands.replies.add(ascii("CREATED")); + RedisLuaVersionedSessionStore store = + new RedisLuaVersionedSessionStore(commands, "service", "test", 1, 1, SECRET); + byte[] payload = "bounded-envelope".getBytes(StandardCharsets.UTF_8); + + SessionCreateOutcome outcome = + store.create( + new SessionCreateCommand( + "opaque-session-id-12345", + payload, + 1, + Instant.parse("2026-07-29T08:00:00Z"), + Instant.parse("2026-07-29T07:00:00Z"), + Duration.ofMinutes(30), + new SessionMutationAttempt("operation-123"))); + + assertThat(outcome).isEqualTo(SessionCreateOutcome.CREATED); + assertThat(commands.loads).hasSize(1); + assertThat(commands.evaluations).hasSize(2); + Invocation invocation = commands.evaluations.getLast(); + assertThat(invocation.replyShape()).isEqualTo(RedisCatalogProgramInvocation.ReplyShape.MULTI); + assertThat(invocation.keys()).hasSize(2); + assertThat(asString(invocation.keys().get(0))) + .doesNotContain("opaque-session-id-12345") + .contains("{"); + assertThat(slotTag(invocation.keys().get(0))).isEqualTo(slotTag(invocation.keys().get(1))); + assertThat(asString(invocation.arguments().get(0))) + .isEqualTo(Base64.getEncoder().encodeToString(payload)); + } + + @Test + void parsesLiveInspectionAndRejectsMalformedOrUnknownReplies() { + RecordingCommands commands = new RecordingCommands(); + byte[] payload = "session-envelope".getBytes(StandardCharsets.UTF_8); + commands.replies.add( + List.of( + bytes("LIVE"), + bytes(Base64.getEncoder().encodeToString(payload)), + bytes("7"), + bytes("1785315600000"), + bytes("1785312000000"))); + RedisLuaVersionedSessionStore store = + new RedisLuaVersionedSessionStore(commands, "service", "test", 1, 1, SECRET); + + SessionInspectionOutcome outcome = + store.inspect( + new SessionInspectionCommand( + "opaque-session-id-12345", Instant.parse("2026-07-29T08:00:00Z"))); + + assertThat(outcome).isInstanceOf(SessionInspectionOutcome.Live.class); + SessionInspectionOutcome.Live live = (SessionInspectionOutcome.Live) outcome; + assertThat(live.payload()).isEqualTo(payload); + assertThat(live.revision()).isEqualTo(7); + + commands.replies.add(ascii("INVENTED")); + assertThatThrownBy( + () -> + store.inspect( + new SessionInspectionCommand( + "opaque-session-id-12345", Instant.parse("2026-07-29T08:00:00Z")))) + .isInstanceOf(RedisSessionProgramCompatibilityException.class); + } + + @Test + void mapsIndeterminateMutationAndDestroysKeyMaterialOnClose() { + RecordingCommands commands = new RecordingCommands(); + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "response lost", + null); + RedisLuaVersionedSessionStore store = + new RedisLuaVersionedSessionStore(commands, "service", "test", 1, 1, SECRET); + + SessionRevokeOutcome outcome = + store.tombstoneAndDelete( + new SessionRevokeCommand( + "opaque-session-id-12345", + 1, + Duration.ofMinutes(5), + new SessionMutationAttempt("operation-123"))); + + assertThat(outcome).isEqualTo(SessionRevokeOutcome.INDETERMINATE); + store.close(); + assertThat(store.destroyed()).isTrue(); + assertThatThrownBy( + () -> + store.inspect( + new SessionInspectionCommand( + "opaque-session-id-12345", Instant.parse("2026-07-29T08:00:00Z")))) + .isInstanceOf(IllegalStateException.class); + } + + private static List ascii(String status) { + return List.of(bytes(status)); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static String asString(byte[] value) { + return new String(value, StandardCharsets.US_ASCII); + } + + private static String slotTag(byte[] key) { + String text = asString(key); + return text.substring(text.indexOf('{') + 1, text.indexOf('}')); + } + + private record Invocation( + String sha1, + RedisCatalogProgramInvocation.ReplyShape replyShape, + List keys, + List arguments) {} + + private static final class RecordingCommands implements RedisStructuredCommands { + + private final ArrayDeque> replies = new ArrayDeque<>(); + private final List loads = new ArrayList<>(); + private final List evaluations = new ArrayList<>(); + private boolean noScriptOnce; + private RedisCommandFailureException failure; + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + loads.add(RedisCatalogProgramInvocation.WireCodec.exactScript(invocation)); + return invocation.sha1(); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + evaluations.add( + new Invocation( + invocation.sha1(), + invocation.replyShape(), + RedisCatalogProgramInvocation.WireCodec.keys(invocation), + RedisCatalogProgramInvocation.WireCodec.arguments(invocation))); + if (failure != null) { + throw failure; + } + if (noScriptOnce) { + noScriptOnce = false; + throw new RedisNoScriptException(); + } + return RedisCatalogProgramReply.multi(replies.removeFirst()); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisMetricRegistryContractTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisMetricRegistryContractTest.java new file mode 100644 index 0000000..9ef6a3a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisMetricRegistryContractTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +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.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +@SuppressWarnings("unchecked") +class RedisMetricRegistryContractTest { + + private static final List EXACT_OUTCOMES = + List.of( + "success", + "hit", + "miss", + "denied", + "contended", + "conflict", + "incompatible", + "unavailable", + "overloaded", + "closed", + "indeterminate", + "stale", + "skipped", + "tombstoned", + "absolute_expired"); + + @Test + void registryMatchesEveryEmittedRedisMeterTypeUnitTagAndClosedValue() throws IOException { + Map> rows = rows(); + + assertRow( + rows, + "redis.capability.operations.total", + "counter", + "total", + Map.of( + "capability", lower(RedisCapabilityObservationEvent.Capability.values()), + "role", lower(RedisCapabilityObservationEvent.Role.values()), + "operation", lower(RedisCapabilityObservationEvent.Operation.values()), + "redis_outcome", EXACT_OUTCOMES, + "certainty", lower(RedisCapabilityObservationEvent.Certainty.values()))); + assertRow( + rows, + "redis.capability.duration.seconds", + "timer", + "seconds", + Map.of( + "capability", lower(RedisCapabilityObservationEvent.Capability.values()), + "role", lower(RedisCapabilityObservationEvent.Role.values()), + "operation", lower(RedisCapabilityObservationEvent.Operation.values()), + "redis_outcome", EXACT_OUTCOMES)); + assertRow( + rows, + "redis.capability.admission.rejected.total", + "counter", + "total", + Map.of( + "role", lower(RedisCapabilityObservationEvent.Role.values()), + "admission", List.of("rejected_saturated", "rejected_closed"))); + assertRow( + rows, + "redis.capability.inflight.total", + "gauge", + "total", + Map.of( + "role", lower(RedisCapabilityObservationEvent.Role.values()), + "state", lower(RedisCapabilityObservationEvent.InFlightState.values()))); + assertRow( + rows, + "redis.capability.readiness.total", + "counter", + "total", + Map.of( + "capability", + List.of("cache", "rate_limit", "idempotency", "efficiency_lease", "session"), + "role", lower(RedisCapabilityObservationEvent.Role.values()), + "state", lower(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State.values()), + "reason", + lower(dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason.values()), + "requirement", lower(RedisCapabilityObservationEvent.Requirement.values()))); + assertRow( + rows, + "redis.capability.lifecycle.drain.total", + "counter", + "total", + Map.of( + "role", lower(RedisCapabilityObservationEvent.Role.values()), + "drain_outcome", lower(RedisCapabilityObservationEvent.DrainOutcome.values()))); + + Map localRequests = rows.get("cache.local.requests.total"); + assertThat(tag(localRequests, "cache_name").get("cardinality_limit")).isEqualTo(50); + assertThat(lower(RedisCapabilityObservationEvent.Outcome.values())) + .containsExactlyElementsOf(EXACT_OUTCOMES); + } + + private static void assertRow( + Map> rows, + String name, + String type, + String unit, + Map> expectedTags) { + Map row = rows.get(name); + assertThat(row).as(name).isNotNull(); + assertThat(row.get("type")).isEqualTo(type); + assertThat(row.get("unit")).isEqualTo(unit); + assertThat(row.get("required_test")).isEqualTo("contract-verification:metrics-cardinality"); + assertThat((Map) row.get("alert_severity_thresholds")).isNotEmpty(); + assertThat(tags(row).keySet()).containsExactlyInAnyOrderElementsOf(expectedTags.keySet()); + expectedTags.forEach( + (tagName, values) -> { + Map tag = tag(row, tagName); + assertThat(tag.get("cardinality_limit")).isEqualTo(values.size()); + assertThat((List) tag.get("allowed_values")).containsExactlyElementsOf(values); + }); + } + + private static Map> rows() throws IOException { + Path registry = locate("docs/registries/metrics.yaml"); + try (InputStream input = Files.newInputStream(registry)) { + Map root = new Yaml().load(input); + List> metrics = (List>) root.get("metrics"); + return metrics.stream() + .collect(java.util.stream.Collectors.toMap(row -> (String) row.get("name"), row -> row)); + } + } + + private static Map> tags(Map row) { + return ((List>) row.get("tags")) + .stream() + .collect( + java.util.stream.Collectors.toMap(tag -> (String) tag.get("name"), tag -> tag)); + } + + private static Map tag(Map row, String name) { + return tags(row).get(name); + } + + private static List lower(Enum[] values) { + return Arrays.stream(values).map(value -> value.name().toLowerCase(Locale.ROOT)).toList(); + } + + private static Path locate(String relative) { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + Path candidate = current.resolve(relative); + if (Files.isRegularFile(candidate)) { + return candidate; + } + current = current.getParent(); + } + throw new IllegalStateException(relative + " not found"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheRecoveryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheRecoveryTest.java new file mode 100644 index 0000000..2813dd6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheRecoveryTest.java @@ -0,0 +1,381 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisOptionalCacheRecoveryTest { + + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "recovery-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-07-29T01:02:03Z"), ZoneOffset.UTC); + + @Test + void typedTransientOptionalColdStartPublishesDormantFallbackThenOneConcurrentRecovery() + throws Exception { + AtomicInteger connects = new AtomicInteger(); + AtomicLong ticker = new AtomicLong(); + RecoveryRuntime candidate = new RecoveryRuntime("cache-main"); + try (RedisCanonicalRoleRegistry registry = + registry( + false, + ticker, + deployment -> { + if (connects.incrementAndGet() == 1) { + throw new RedisTemporaryConnectionException(); + } + return candidate; + })) { + var dormant = registry.snapshot().roles().getFirst(); + assertThat(dormant.state()).isEqualTo(State.UNAVAILABLE); + assertThat(dormant.reason()).isEqualTo(Reason.COMMAND_UNAVAILABLE); + assertThatThrownBy(() -> registry.router(RedisRole.CACHE).read("key")) + .isInstanceOf(RedisCommandFailureException.class) + .hasMessageNotContaining("cache-main"); + + RedisInvalidationTransport.Subscription subscription = + registry + .router(RedisRole.CACHE) + .subscribe( + "cache-events".getBytes(StandardCharsets.US_ASCII), + new RedisInvalidationTransport.Listener() { + @Override + public void onMessage(byte[] wireMessage) {} + + @Override + public void onDisconnected() {} + }); + ticker.addAndGet(Duration.ofSeconds(6).toNanos()); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var snapshots = + java.util.stream.IntStream.range(0, 32) + .mapToObj(ignored -> executor.submit(registry::snapshot)) + .toList(); + for (var snapshot : snapshots) { + snapshot.get(1, TimeUnit.SECONDS); + } + } + + assertThat(connects).hasValue(2); + assertThat(candidate.probes()).isEqualTo(2); + assertThat(candidate.subscriptions()).isEqualTo(1); + assertThat(registry.snapshot().roles().getFirst().state()).isEqualTo(State.AVAILABLE); + subscription.close(); + } + } + + @Test + void requiredRoleAndUnknownOptionalFailureRemainStartupFatal() { + assertThatThrownBy( + () -> + registry( + true, + new AtomicLong(), + deployment -> { + throw new RedisTemporaryConnectionException(); + })) + .isInstanceOf(RedisTemporaryConnectionException.class); + + assertThatThrownBy( + () -> + registry( + false, + new AtomicLong(), + deployment -> { + throw new IllegalStateException("permanent material or TLS failure"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("permanent"); + } + + @Test + void terminalRecoveryMismatchIsNeverInstalledOrRetried() { + AtomicInteger connects = new AtomicInteger(); + AtomicLong ticker = new AtomicLong(); + RecoveryRuntime incompatible = new RecoveryRuntime("cache-main"); + incompatible.aclStatus("VERSION_UNSUPPORTED"); + try (RedisCanonicalRoleRegistry registry = + registry( + false, + ticker, + deployment -> { + if (connects.incrementAndGet() == 1) { + throw new RedisTemporaryConnectionException(); + } + return incompatible; + })) { + ticker.addAndGet(Duration.ofSeconds(6).toNanos()); + var terminal = registry.snapshot().roles().getFirst(); + assertThat(terminal.reason()).isEqualTo(Reason.SERVER_VERSION_UNSUPPORTED); + assertThat(incompatible.closed()).isTrue(); + + ticker.addAndGet(Duration.ofSeconds(30).toNanos()); + assertThat(registry.snapshot().roles().getFirst().reason()) + .isEqualTo(Reason.SERVER_VERSION_UNSUPPORTED); + assertThat(connects).hasValue(2); + } + } + + @Test + void closeDuringReconnectClosesLateCandidateExactlyOnceAndPreventsFurtherAttempts() + throws Exception { + AtomicInteger connects = new AtomicInteger(); + AtomicLong ticker = new AtomicLong(); + CountDownLatch reconnectStarted = new CountDownLatch(1); + CountDownLatch releaseReconnect = new CountDownLatch(1); + RecoveryRuntime lateCandidate = new RecoveryRuntime("cache-main"); + RedisCanonicalRoleRegistry registry = + registry( + false, + ticker, + deployment -> { + if (connects.incrementAndGet() == 1) { + throw new RedisTemporaryConnectionException(); + } + reconnectStarted.countDown(); + await(releaseReconnect); + return lateCandidate; + }); + ticker.addAndGet(Duration.ofSeconds(6).toNanos()); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var recovery = executor.submit(registry::snapshot); + assertThat(reconnectStarted.await(1, TimeUnit.SECONDS)).isTrue(); + registry.close(); + releaseReconnect.countDown(); + assertThat(recovery.get(1, TimeUnit.SECONDS).roles().getFirst().reason()) + .isEqualTo(Reason.ROUTE_CLOSED); + } + + assertThat(lateCandidate.closeCount()).isEqualTo(1); + ticker.addAndGet(Duration.ofSeconds(30).toNanos()); + registry.snapshot(); + assertThat(connects).hasValue(2); + } + + @Test + void closeAfterQualifiedSwapStartsCannotPublishATransientAvailableObservation() throws Exception { + AtomicInteger connects = new AtomicInteger(); + AtomicLong ticker = new AtomicLong(); + RecoveryRuntime candidate = new RecoveryRuntime("cache-main"); + candidate.blockProbe(2); + RedisCanonicalRoleRegistry registry = + registry( + false, + ticker, + deployment -> { + if (connects.incrementAndGet() == 1) { + throw new RedisTemporaryConnectionException(); + } + return candidate; + }); + ticker.addAndGet(Duration.ofSeconds(6).toNanos()); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var recovery = executor.submit(registry::snapshot); + assertThat(candidate.awaitBlockedProbe()).isTrue(); + var close = executor.submit(registry::close); + while (!registry.isClosed()) { + Thread.onSpinWait(); + } + candidate.releaseBlockedProbe(); + + assertThat(recovery.get(1, TimeUnit.SECONDS).roles().getFirst().reason()) + .isEqualTo(Reason.ROUTE_CLOSED); + close.get(1, TimeUnit.SECONDS); + } + + assertThat(candidate.closeCount()).isEqualTo(1); + } + + private static RedisCanonicalRoleRegistry registry( + boolean required, + AtomicLong ticker, + RedisCanonicalRoleRegistry.RuntimeFactory runtimeFactory) { + RedisDeploymentSettings.Standalone deployment = + new RedisDeploymentSettings.Standalone( + "cache-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "runtime", "secret://environment/REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/REDIS_TRUST_PEM")); + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.CACHE, deployment), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + runtimeFactory, + Map.of(RedisRole.CACHE, new RedisRoleBinding("cache-main", required, "allkeys-lfu")), + Map.of(RedisRole.CACHE, Set.of(Capability.CACHE)), + CLOCK, + Duration.ofSeconds(5), + Duration.ofSeconds(15), + ticker::get); + } + + private static final class RecoveryRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final Map values = new HashMap<>(); + private final Set loaded = new HashSet<>(); + private String aclStatus = "ACL_OK"; + private int probes; + private int subscriptions; + private int closeCount; + private int blockedProbeNumber = -1; + private final CountDownLatch blockedProbe = new CountDownLatch(1); + private final CountDownLatch releaseProbe = new CountDownLatch(1); + + private RecoveryRuntime(String deploymentId) { + this.deploymentId = deploymentId; + } + + private void aclStatus(String status) { + aclStatus = status; + } + + private int probes() { + return probes; + } + + private int subscriptions() { + return subscriptions; + } + + private boolean closed() { + return closeCount > 0; + } + + private int closeCount() { + return closeCount; + } + + private void blockProbe(int number) { + blockedProbeNumber = number; + } + + private boolean awaitBlockedProbe() throws InterruptedException { + return blockedProbe.await(1, TimeUnit.SECONDS); + } + + private void releaseBlockedProbe() { + releaseProbe.countDown(); + } + + @Override + public void probe(Duration timeout) { + probes++; + if (probes == blockedProbeNumber) { + blockedProbe.countDown(); + await(releaseProbe); + } + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + byte[] value = + values.get(new String(RedisPhysicalKey.WireCodec.copy(key), StandardCharsets.UTF_8)); + return value == null ? null : value.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + values.put( + new String(RedisPhysicalKey.WireCodec.copy(key), StandardCharsets.UTF_8), + value.copyEncoded()); + } + + @Override + public long delete(RedisPhysicalKey key) { + return values.remove(new String(RedisPhysicalKey.WireCodec.copy(key), StandardCharsets.UTF_8)) + == null + ? 0 + : 1; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + String sha1 = invocation.sha1(); + if (!loaded.contains(sha1)) { + throw new RedisNoScriptException(); + } + if (sha1.equals(RedisScriptRecovery.sha1(RedisSemanticAclProbeCatalog.scriptBytes()))) { + return RedisCatalogProgramReply.value(aclStatus.getBytes(StandardCharsets.US_ASCII)); + } + return invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + ? RedisCatalogProgramReply.multi(List.of()) + : RedisCatalogProgramReply.value("EXISTS".getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + loaded.add(invocation.sha1()); + return invocation.sha1(); + } + + @Override + public Subscription subscribe(byte[] channel, Listener listener) { + subscriptions++; + return () -> {}; + } + + @Override + public void close() { + closeCount++; + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(1, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting for reconnect latch"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for reconnect latch", exception); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java new file mode 100644 index 0000000..5c9a8a1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPhysicalKeyTestFactory.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; + +/** Test-only backdoor for corruption and terminal-adapter boundary tests. */ +final class RedisPhysicalKeyTestFactory { + + private RedisPhysicalKeyTestFactory() {} + + static RedisPhysicalKey fromEncoded(byte[] encoded) { + try { + Constructor constructor = + RedisPhysicalKey.class.getDeclaredConstructor(byte[].class); + constructor.setAccessible(true); + return constructor.newInstance((Object) encoded.clone()); + } catch (ReflectiveOperationException exception) { + throw new LinkageError("RedisPhysicalKey test constructor is unavailable", exception); + } + } + + static RedisPhysicalKey fromUtf8(String value) { + return fromEncoded(value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBitmapTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBitmapTest.java new file mode 100644 index 0000000..5ab02e6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBitmapTest.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class RedisPrimitiveBitmapTest { + + @Test + void offsetIsRestrictedToFixedDescriptorDomain() { + RedisBitmapPrimitives bitmaps = + RedisPrimitiveCatalog.standard().bitmaps(new RedisPrimitiveTestCommands()); + + bitmaps.offset(8_388_607); + assertThatThrownBy(() -> bitmaps.offset(8_388_608)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fixed descriptor domain"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBoundaryVectorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBoundaryVectorTest.java new file mode 100644 index 0000000..0973c1a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveBoundaryVectorTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RedisPrimitiveBoundaryVectorTest { + + private final RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + private final RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + + @Test + void typedFacadeValuesAcceptExactByteLimitsAndRejectOneByteOverBeforeDispatch() { + RedisStringValuePrimitives strings = catalog.strings(commands); + RedisListPrimitives lists = catalog.lists(commands); + RedisHyperLogLogPrimitives hll = catalog.hyperLogLogs(commands); + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisSetPrimitives sets = catalog.sets(commands); + RedisSortedSetPrimitives sorted = catalog.sortedSets(commands); + RedisGeoPrimitives geo = catalog.geo(commands); + + assertThat(strings.value("s".repeat(16_000)).encodedLength()).isEqualTo(16_000); + assertThat(lists.value("l".repeat(16_000)).encodedLength()).isEqualTo(16_000); + assertThat(hll.element("h".repeat(16_000)).encodedLength()).isEqualTo(16_000); + assertThat(hashes.field("f".repeat(1_024)).encodedLength()).isEqualTo(1_024); + assertThat(hashes.value("v".repeat(16_000)).encodedLength()).isEqualTo(16_000); + assertThat(sets.member("s".repeat(4_096)).encodedLength()).isEqualTo(4_096); + assertThat(sorted.member("z".repeat(4_096)).encodedLength()).isEqualTo(4_096); + assertThat(geo.member("g".repeat(4_096)).encodedLength()).isEqualTo(4_096); + + assertThatThrownBy(() -> strings.value("s".repeat(16_001))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lists.value("l".repeat(16_001))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> hll.element("h".repeat(16_001))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> hashes.field("f".repeat(1_025))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> hashes.value("v".repeat(16_001))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> sets.member("s".repeat(4_097))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> sorted.member("z".repeat(4_097))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> geo.member("g".repeat(4_097))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(commands.invocations()).isEmpty(); + } + + @Test + void scoreBitmapAndSignedCounterBoundariesAreTypedAndFailClosed() { + assertThat(RedisSortedSetScore.of("1000000000000000").canonical()) + .isEqualTo("1000000000000000"); + assertThat(RedisSortedSetScore.of("-1000000000000000").canonical()) + .isEqualTo("-1000000000000000"); + assertThatThrownBy(() -> RedisSortedSetScore.of("1000000000000001")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RedisSortedSetScore.of("-1000000000000001")) + .isInstanceOf(IllegalArgumentException.class); + + RedisBitmapPrimitives bitmaps = catalog.bitmaps(commands); + assertThat(bitmaps.offset(8_388_607).value()).isEqualTo(8_388_607); + assertThat(bitmaps.byteOffset(1_048_575).value()).isEqualTo(1_048_575); + assertThatThrownBy(() -> bitmaps.offset(8_388_608)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> bitmaps.byteOffset(1_048_576)) + .isInstanceOf(IllegalArgumentException.class); + + RedisCounterPrimitives counters = catalog.counters(commands); + counters.increment( + counters.key("boundary", "signed"), + Long.MIN_VALUE, + Long.MIN_VALUE, + Long.MAX_VALUE, + Duration.ofSeconds(1)); + RedisPrimitiveInvocation.CounterArguments arguments = + (RedisPrimitiveInvocation.CounterArguments) commands.lastInvocation().arguments(); + assertThat(arguments.delta()).isEqualTo(Long.MIN_VALUE); + assertThat(arguments.minimum()).isEqualTo(Long.MIN_VALUE); + assertThat(arguments.maximum()).isEqualTo(Long.MAX_VALUE); + assertThatThrownBy( + () -> + counters.increment( + counters.key("boundary", "inverted"), + 1, + Long.MAX_VALUE, + Long.MIN_VALUE, + Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest(name = "{0}") + @ValueSource( + strings = { + "string-set", + "string-set-if-absent", + "string-replace", + "string-cas-absent", + "string-cas-value", + "counter", + "hash-admission", + "hash-revision-cas", + "set-admission", + "sorted-set-admission", + "list-admission", + "geo-admission" + }) + void everyTtlBearingPrimitiveFamilyEnforcesTheCompleteMillisecondMatrixBeforeDispatch( + String family) { + assertRejectedBeforeDispatch(family, Duration.ofNanos(999_999)); + assertAcceptedWithOneDispatch(family, Duration.ofMillis(1)); + assertAcceptedWithOneDispatch(family, Duration.ofDays(31)); + assertRejectedBeforeDispatch(family, Duration.ofDays(31).plusMillis(1)); + } + + private void assertRejectedBeforeDispatch(String family, Duration ttl) { + int before = commands.invocations().size(); + assertThatThrownBy(() -> invokeTtlFamily(family, ttl)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(commands.invocations()).hasSize(before); + } + + private void assertAcceptedWithOneDispatch(String family, Duration ttl) { + int before = commands.invocations().size(); + assertThatCode(() -> invokeTtlFamily(family, ttl)).doesNotThrowAnyException(); + assertThat(commands.invocations()).hasSize(before + 1); + } + + private void invokeTtlFamily(String family, Duration ttl) { + RedisStringValuePrimitives strings = catalog.strings(commands); + RedisCounterPrimitives counters = catalog.counters(commands); + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisSetPrimitives sets = catalog.sets(commands); + RedisSortedSetPrimitives sorted = catalog.sortedSets(commands); + RedisListPrimitives lists = catalog.lists(commands); + RedisGeoPrimitives geo = catalog.geo(commands); + switch (family) { + case "string-set" -> strings.set(strings.key("ttl", family), strings.value("value"), ttl); + case "string-set-if-absent" -> + strings.setIfAbsent(strings.key("ttl", family), strings.value("value"), ttl); + case "string-replace" -> + strings.replace(strings.key("ttl", family), strings.value("value"), ttl); + case "string-cas-absent" -> + strings.compareSetAbsent(strings.key("ttl", family), strings.value("value"), ttl); + case "string-cas-value" -> + strings.compareSetValue( + strings.key("ttl", family), strings.value("expected"), strings.value("value"), ttl); + case "counter" -> counters.increment(counters.key("ttl", family), 1, 0, 10, ttl); + case "hash-admission" -> + hashes.put(hashes.key("ttl", family), hashes.field("field"), hashes.value("value"), ttl); + case "hash-revision-cas" -> + hashes.compareRevision( + hashes.key("ttl", family), + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.ABSENT, + "", + "revision_1", + hashes.value("value"), + ttl); + case "set-admission" -> sets.admit(sets.key("ttl", family), sets.member("member"), ttl); + case "sorted-set-admission" -> + sorted.admitOrUpdate( + sorted.key("ttl", family), sorted.member("member"), RedisSortedSetScore.of("1"), ttl); + case "list-admission" -> lists.admit(lists.key("ttl", family), lists.value("value"), ttl); + case "geo-admission" -> + geo.admitOrUpdate( + geo.key("ttl", family), geo.member("member"), new RedisGeoCoordinate(127, 37), ttl); + default -> throw new IllegalArgumentException("unknown TTL test family"); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommandRuntimeTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommandRuntimeTest.java new file mode 100644 index 0000000..893c4c0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCommandRuntimeTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.List; +import java.util.function.LongSupplier; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveCommandRuntimeTest { + + @Test + void executorPreservesCatalogIdentityBoundsSlotAndTotalDeadline() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + CapturingCommands commands = new CapturingCommands(); + RedisPrimitiveExecutor executor = new RedisPrimitiveExecutor(catalog, commands); + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.STRING_GET); + RedisPrimitiveKey key = catalog.keyFactory(RedisPrimitiveId.STRING_GET).key("tenant-a", "one"); + + RedisPrimitiveReply reply = + executor.execute( + RedisPrimitiveId.STRING_GET, + List.of(key), + RedisPrimitiveInvocation.NoArguments.INSTANCE); + + assertThat(reply.status()).isEqualTo(RedisPrimitiveReply.Status.MISSING); + assertThat(commands.lastInvocation.descriptor()).isSameAs(descriptor); + assertThat(commands.lastInvocation.keys()).containsExactly(key); + assertThat(commands.lastInvocation.remainingDeadline()) + .isPositive() + .isLessThanOrEqualTo(descriptor.totalDeadline()); + assertThat(commands.lastInvocation.encodedRequestBytes()).isEqualTo(key.encodedLength()); + } + + @Test + void invocationRejectsForeignKeysOversizeValuesAndExpiredDeadlineBeforeDispatch() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + CapturingCommands commands = new CapturingCommands(); + RedisPrimitiveExecutor executor = new RedisPrimitiveExecutor(catalog, commands); + RedisPrimitiveKey foreign = + catalog.keyFactory(RedisPrimitiveId.HASH_GET).key("tenant-a", "one"); + + assertThatThrownBy( + () -> + executor.execute( + RedisPrimitiveId.STRING_GET, + List.of(foreign), + RedisPrimitiveInvocation.NoArguments.INSTANCE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("family"); + assertThat(commands.calls).isZero(); + + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.STRING_SET_PX); + RedisPrimitiveKey key = + catalog.keyFactory(RedisPrimitiveId.STRING_SET_PX).key("tenant-a", "one"); + assertThatThrownBy( + () -> + executor.execute( + RedisPrimitiveId.STRING_SET_PX, + List.of(key), + new RedisPrimitiveInvocation.ExpiringWrite( + RedisPrimitiveValue.copyOf( + new byte[descriptor.maximumValueBytes() + 1], + descriptor.maximumValueBytes()), + Duration.ofSeconds(1), + RedisPrimitiveInvocation.WriteCondition.ALWAYS))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(commands.calls).isZero(); + } + + @Test + void mutationResponseLossIsIndeterminateAndIsNeverRetried() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveKey key = + catalog.keyFactory(RedisPrimitiveId.STRING_SET_PX).key("tenant-a", "one"); + RedisPrimitiveCommands losing = + invocation -> { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "lost response", + null); + }; + RedisStringValuePrimitives strings = catalog.strings(losing); + + RedisPrimitiveMutationResult result = + strings.set(key, strings.value("value"), Duration.ofSeconds(10)); + + assertThat(result.certainty()).isEqualTo(RedisPrimitiveMutationResult.Certainty.INDETERMINATE); + assertThat(result.status()).isEqualTo(RedisPrimitiveMutationResult.Status.UNKNOWN); + } + + @Test + void totalDeadlineFailsClosedOnExpiryAndBackwardTickerButSurvivesNanoWrap() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveKey key = catalog.keyFactory(RedisPrimitiveId.STRING_GET).key("tenant-a", "one"); + long budget = catalog.descriptor(RedisPrimitiveId.STRING_GET).totalDeadline().toNanos(); + + CapturingCommands expiredCommands = new CapturingCommands(); + RedisPrimitiveExecutor expired = + new RedisPrimitiveExecutor(catalog, expiredCommands, sequence(100, 100 + budget + 1)); + assertThatThrownBy( + () -> + expired.execute( + RedisPrimitiveId.STRING_GET, + List.of(key), + RedisPrimitiveInvocation.NoArguments.INSTANCE)) + .isInstanceOf(RedisCommandFailureException.class); + assertThat(expiredCommands.calls).isZero(); + + CapturingCommands backwardCommands = new CapturingCommands(); + RedisPrimitiveExecutor backward = + new RedisPrimitiveExecutor(catalog, backwardCommands, sequence(100, 99)); + assertThatThrownBy( + () -> + backward.execute( + RedisPrimitiveId.STRING_GET, + List.of(key), + RedisPrimitiveInvocation.NoArguments.INSTANCE)) + .isInstanceOf(RedisCommandFailureException.class); + assertThat(backwardCommands.calls).isZero(); + + CapturingCommands wrapCommands = new CapturingCommands(); + RedisPrimitiveExecutor wrap = + new RedisPrimitiveExecutor( + catalog, wrapCommands, sequence(Long.MAX_VALUE - 5, Long.MIN_VALUE + 5)); + assertThat( + wrap.execute( + RedisPrimitiveId.STRING_GET, + List.of(key), + RedisPrimitiveInvocation.NoArguments.INSTANCE) + .status()) + .isEqualTo(RedisPrimitiveReply.Status.MISSING); + } + + @Test + void postWriteProtocolCorruptionCanNeverBeReportedNotApplied() { + RedisPrimitiveDescriptor descriptor = + RedisPrimitiveCatalog.standard().descriptor(RedisPrimitiveId.ZSET_TRIM_BOUNDED); + RedisPrimitiveReply reply = + RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.CORRUPT_AFTER_WRITE, + List.of(), + java.util.OptionalLong.empty(), + "RESULT"); + + assertThat(RedisPrimitiveMutationResult.from(reply).certainty()) + .isEqualTo(RedisPrimitiveMutationResult.Certainty.INDETERMINATE); + } + + private static LongSupplier sequence(long... values) { + ArrayDeque sequence = new ArrayDeque<>(java.util.Arrays.stream(values).boxed().toList()); + return () -> sequence.size() == 1 ? sequence.getFirst() : sequence.removeFirst(); + } + + private static final class CapturingCommands implements RedisPrimitiveCommands { + + private RedisPrimitiveInvocation lastInvocation; + private int calls; + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + calls++; + lastInvocation = invocation; + return RedisPrimitiveReply.missing(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCompletenessMatrixTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCompletenessMatrixTest.java new file mode 100644 index 0000000..4e55c92 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCompletenessMatrixTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.EnumSet; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveCompletenessMatrixTest { + + @Test + void everyCatalogIdIsReachableThroughAFacadeAndHasProgramOrDirectRuntimeDispatch() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + Duration ttl = Duration.ofSeconds(30); + + RedisStringValuePrimitives strings = catalog.strings(commands); + RedisPrimitiveKey stringKey = strings.key("tenant", "string"); + RedisPrimitiveValue stringValue = strings.value("value"); + strings.get(stringKey); + strings.multiGet(List.of(stringKey)); + strings.set(stringKey, stringValue, ttl); + strings.setIfAbsent(stringKey, stringValue, ttl); + strings.replace(stringKey, stringValue, ttl); + strings.compareSetAbsent(stringKey, stringValue, ttl); + strings.compareDelete(stringKey, stringValue); + + RedisCounterPrimitives counters = catalog.counters(commands); + RedisPrimitiveKey counterKey = counters.key("tenant", "counter"); + counters.read(counterKey); + counters.increment(counterKey, -1, -10, 10, ttl); + + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisPrimitiveKey hashKey = hashes.key("tenant", "hash"); + RedisPrimitiveValue field = hashes.field("field"); + RedisPrimitiveValue hashValue = hashes.value("value"); + hashes.get(hashKey, field); + hashes.multiGet(hashKey, List.of(field)); + hashes.put(hashKey, field, hashValue, ttl); + hashes.delete(hashKey, List.of(field)); + RedisPrimitiveDescriptor hashScan = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE); + hashes.scan(hashKey, RedisPrimitiveCursor.initial(catalog, hashScan, hashKey, 7), 7); + hashes.compareRevision( + hashKey, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.ABSENT, + "", + "r1", + hashValue, + ttl); + + RedisSetPrimitives sets = catalog.sets(commands); + RedisPrimitiveKey setKey = sets.key("tenant", "set"); + RedisPrimitiveValue member = sets.member("member"); + sets.contains(setKey, member); + sets.remove(setKey, List.of(member)); + sets.cardinality(setKey); + RedisPrimitiveDescriptor setScan = catalog.descriptor(RedisPrimitiveId.SET_SCAN_PAGE); + sets.scan(setKey, RedisPrimitiveCursor.initial(catalog, setScan, setKey, 7), 7); + sets.admit(setKey, member, ttl); + + RedisSortedSetPrimitives sorted = catalog.sortedSets(commands); + RedisPrimitiveKey sortedKey = sorted.key("tenant", "sorted"); + RedisPrimitiveValue sortedMember = sorted.member("member"); + RedisSortedSetScore zero = RedisSortedSetScore.of("0"); + RedisSortedSetScore one = RedisSortedSetScore.of("1"); + sorted.admitOrUpdate(sortedKey, sortedMember, one, ttl); + sorted.remove(sortedKey, List.of(sortedMember)); + sorted.count(sortedKey, zero, one); + sorted.rankPage(sortedKey, 0, 10); + sorted.scorePage(sortedKey, zero, one, 0, 10); + sorted.trimBelowOrEqual(sortedKey, zero); + + RedisListPrimitives lists = catalog.lists(commands); + RedisPrimitiveKey listKey = lists.key("tenant", "list"); + lists.admit(listKey, lists.value("value"), ttl); + lists.pop(listKey); + lists.trimNewest(listKey, 10); + + RedisBitmapPrimitives bitmaps = catalog.bitmaps(commands); + RedisPrimitiveKey bitmapKey = bitmaps.key("tenant", "bitmap"); + RedisBitmapOffset bit = bitmaps.offset(7); + bitmaps.get(bitmapKey, bit); + bitmaps.set(bitmapKey, bit, true); + bitmaps.count(bitmapKey, bitmaps.byteOffset(0), bitmaps.byteOffset(1)); + + RedisHyperLogLogPrimitives hll = catalog.hyperLogLogs(commands); + RedisPrimitiveKey hllDestination = hll.key("tenant", "hll-destination"); + RedisPrimitiveKey hllSource = hll.key("tenant", "hll-source"); + hll.add(hllDestination, List.of(hll.element("element"))); + hll.count(hllDestination); + hll.merge(hllDestination, List.of(hllSource)); + + RedisGeoPrimitives geo = catalog.geo(commands); + RedisPrimitiveKey geoKey = geo.key("tenant", "geo"); + RedisGeoCoordinate center = new RedisGeoCoordinate(127.0, 37.5); + geo.admitOrUpdate(geoKey, geo.member("member"), center, ttl); + geo.search(geoKey, center, 1000, 10, RedisPrimitiveInvocation.GeoArguments.Sort.ASCENDING); + + EnumSet reached = EnumSet.noneOf(RedisPrimitiveId.class); + commands.invocations().forEach(value -> reached.add(value.descriptor().id())); + assertThat(reached).containsExactlyInAnyOrder(RedisPrimitiveId.values()); + assertThat(catalog.descriptors()) + .allSatisfy( + descriptor -> + assertThat( + descriptor.programId() != null + || RedisTopologyCommandRuntime.supportsDirect(descriptor.id())) + .as(descriptor.id().name()) + .isTrue()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCounterTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCounterTest.java new file mode 100644 index 0000000..02c316f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveCounterTest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveCounterTest { + + @Test + void incrementUsesAtomicInitialTtlProgramAndSignedBounds() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisCounterPrimitives counters = catalog.counters(commands); + + counters.increment(counters.key("tenant-a", "quota"), -2, -10, 10, Duration.ofSeconds(30)); + + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1); + assertThat(commands.lastInvocation().arguments()) + .isInstanceOf(RedisPrimitiveInvocation.CounterArguments.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptorTest.java new file mode 100644 index 0000000..46ab2c6 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveDescriptorTest.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.EnumSet; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveDescriptorTest { + + @Test + void everyPrimitiveHasClosedRoleKeyByteElementTtlSlotDeadlineAndCertaintyBounds() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + + assertThat(catalog.descriptors()).isNotEmpty(); + assertThat(catalog.descriptors()) + .allSatisfy( + descriptor -> { + assertThat(descriptor.boundRole()).isNotNull(); + assertThat(descriptor.keyFamily()).matches("[a-z][a-z0-9-]{2,31}"); + assertThat(descriptor.keyVersion()).isPositive(); + assertThat(descriptor.maximumKeyBytes()).isBetween(16, 512); + assertThat(descriptor.maximumValueBytes()).isBetween(1, 1_048_576); + assertThat(descriptor.maximumFieldBytes()).isBetween(1, 1_024); + assertThat(descriptor.maximumMemberBytes()).isBetween(1, 4_096); + assertThat(descriptor.maximumKeys()).isBetween(1, 32); + assertThat(descriptor.maximumElements()).isBetween(1, 1_024); + assertThat(descriptor.maximumEncodedBytes()).isBetween(1, 4_194_304); + assertThat(descriptor.maximumResultBytes()).isBetween(1, 4_194_304); + assertThat(descriptor.maximumEncodedBytes() + descriptor.maximumResultBytes()) + .isLessThanOrEqualTo(65_536); + assertThat(descriptor.totalDeadline()) + .isPositive() + .isLessThanOrEqualTo(Duration.ofSeconds(5)); + assertThat(descriptor.lowCardinalityOperation()) + .matches("redis\\.primitive\\.[a-z0-9.-]+"); + assertThat(descriptor.retrySafety()).isNotNull(); + assertThat(descriptor.timeoutCertainty()).isNotNull(); + assertThat(descriptor.ttlPolicy()).isNotNull(); + assertThat(descriptor.slotRule()).isNotNull(); + }); + } + + @Test + void everyReadPreservesTtlAndBoundedGetIsReadOnlyDespiteUsingAProgram() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + + assertThat(catalog.descriptor(RedisPrimitiveId.STRING_GET).programId()) + .isEqualTo(RedisProgramId.BOUNDED_GET_V1); + assertThat(catalog.descriptor(RedisPrimitiveId.STRING_GET).ttlPolicy()) + .isEqualTo(RedisPrimitiveDescriptor.TtlPolicy.PRESERVE_EXISTING); + assertThat(catalog.descriptor(RedisPrimitiveId.STRING_GET).retrySafety()) + .isEqualTo(RedisPrimitiveDescriptor.RetrySafety.SAFE_READ); + } + + @Test + void catalogCoversNineStructuresAndLocksNonAuthoritativeSemantics() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + + assertThat( + catalog.descriptors().stream() + .map(RedisPrimitiveDescriptor::structure) + .collect(java.util.stream.Collectors.toSet())) + .containsExactlyInAnyOrderElementsOf(EnumSet.allOf(RedisPrimitiveStructure.class)); + assertThat(catalog.descriptor(RedisPrimitiveId.LIST_ADMIT).semanticClass()) + .isEqualTo(RedisPrimitiveSemanticClass.BEST_EFFORT_NOT_MESSAGING); + assertThat(catalog.descriptor(RedisPrimitiveId.BITMAP_SET).semanticClass()) + .isEqualTo(RedisPrimitiveSemanticClass.NON_AUTHORITATIVE_FIXED_DOMAIN_BITMAP); + assertThat(catalog.descriptor(RedisPrimitiveId.HLL_ADD).semanticClass()) + .isEqualTo(RedisPrimitiveSemanticClass.APPROXIMATE_NON_AUTHORITATIVE_HLL); + assertThat(catalog.descriptor(RedisPrimitiveId.GEO_SEARCH).semanticClass()) + .isEqualTo(RedisPrimitiveSemanticClass.PRIVACY_SENSITIVE_NON_AUTHORITATIVE_GEO); + assertThat( + RedisPrimitiveSemanticClass.APPROXIMATE_NON_AUTHORITATIVE_HLL + .authoritativeCorrectnessAllowed()) + .isFalse(); + } + + @Test + void typedKeyFactoryRejectsWrongFamilyVersionOversizeAndCrossSlotBulk() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.STRING_MGET); + RedisPrimitiveKeyFactory factory = catalog.keyFactory(RedisPrimitiveId.STRING_MGET); + RedisPrimitiveKey first = factory.key("tenant-a", "alpha"); + RedisPrimitiveKey sameSlot = factory.key("tenant-a", "beta"); + RedisPrimitiveKey otherSlot = factory.key("tenant-b", "gamma"); + + assertThat(first.family()).isEqualTo(descriptor.keyFamily()); + assertThat(first.version()).isEqualTo(descriptor.keyVersion()); + assertThat(descriptor.validateKeys(java.util.List.of(first, sameSlot))) + .containsExactly(first, sameSlot); + assertThatThrownBy(() -> descriptor.validateKeys(java.util.List.of(first, otherSlot))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("same slot"); + assertThatThrownBy(() -> factory.key("tenant-a", "x".repeat(600))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bounds"); + } + + @Test + void directCollectionCreationIsPersistentOnlyAndZsetTrimIsGuardedByProgram() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + + assertThat( + EnumSet.of( + RedisPrimitiveId.BITMAP_SET, + RedisPrimitiveId.HLL_ADD, + RedisPrimitiveId.HLL_MERGE_SAME_SLOT) + .stream() + .map(catalog::descriptor) + .map(RedisPrimitiveDescriptor::ttlPolicy)) + .containsOnly(RedisPrimitiveDescriptor.TtlPolicy.PERSISTENT_ONLY); + assertThat(catalog.descriptor(RedisPrimitiveId.ZSET_TRIM_BOUNDED).programId()) + .isEqualTo(RedisProgramId.ZSET_BOUNDED_TRIM_V1); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveGeoTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveGeoTest.java new file mode 100644 index 0000000..f1cc3c3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveGeoTest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveGeoTest { + + @Test + void coordinateIsRedactedAndGrowthUsesBoundedGeoProgram() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisGeoPrimitives geo = catalog.geo(commands); + RedisGeoCoordinate coordinate = new RedisGeoCoordinate(127.0, 37.5); + + geo.admitOrUpdate( + geo.key("tenant-a", "places"), geo.member("office"), coordinate, Duration.ofSeconds(30)); + + assertThat(coordinate).hasToString("RedisGeoCoordinate[redacted]"); + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.BOUNDED_GEO_ADMISSION_V1); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashTest.java new file mode 100644 index 0000000..f5c5bca --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHashTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveHashTest { + + @Test + void putInjectsDescriptorCapacityAndRevisionAbsentUsesClosedSentinel() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisPrimitiveKey key = hashes.key("tenant-a", "state"); + + hashes.put(key, hashes.field("name"), hashes.value("value"), Duration.ofSeconds(30)); + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1); + + hashes.compareRevision( + key, + RedisPrimitiveInvocation.HashRevisionArguments.ExpectedKind.ABSENT, + "", + "rev_1", + hashes.value("value"), + Duration.ofSeconds(30)); + RedisPrimitiveInvocation.HashRevisionArguments arguments = + (RedisPrimitiveInvocation.HashRevisionArguments) commands.lastInvocation().arguments(); + assertThat( + new String( + arguments.programValues().get(1).copyEncoded(), + java.nio.charset.StandardCharsets.UTF_8)) + .isEqualTo("-"); + } + + @Test + void scanCursorIsBoundToTheExactPhysicalKeyAndUsesFullHashCardinalityCeiling() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE); + RedisPrimitiveKey first = hashes.key("same-slot", "first"); + RedisPrimitiveKey second = hashes.key("same-slot", "second"); + + RedisPrimitivePage firstPage = + hashes + .scan(first, RedisPrimitiveCursor.initial(catalog, descriptor, first, 11), 11) + .page() + .orElseThrow(); + RedisPrimitiveInvocation.ScanPageArguments arguments = + (RedisPrimitiveInvocation.ScanPageArguments) commands.lastInvocation().arguments(); + assertThat(arguments.corruptionCeiling()).isEqualTo(256); + + assertThat(hashes.scan(first, firstPage.nextCursor(), 11).page().orElseThrow().complete()) + .isTrue(); + assertThatThrownBy(() -> hashes.scan(second, firstPage.nextCursor(), 11)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cursor"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHyperLogLogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHyperLogLogTest.java new file mode 100644 index 0000000..37a9adc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveHyperLogLogTest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveHyperLogLogTest { + + @Test + void hllIsApproximateAndMergeFanInIsSameSlotAndBounded() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisHyperLogLogPrimitives hll = catalog.hyperLogLogs(new RedisPrimitiveTestCommands()); + + assertThat(catalog.descriptor(RedisPrimitiveId.HLL_COUNT).semanticClass()) + .isEqualTo(RedisPrimitiveSemanticClass.APPROXIMATE_NON_AUTHORITATIVE_HLL); + RedisPrimitiveKey destination = hll.key("tenant-a", "all"); + assertThatThrownBy(() -> hll.merge(destination, List.of(hll.key("tenant-b", "one")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("same slot"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveListTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveListTest.java new file mode 100644 index 0000000..cd0e119 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveListTest.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveListTest { + + @Test + void pushUsesAdmissionAndTrimUsesRemovalGuardWithoutBlockingPop() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisListPrimitives lists = catalog.lists(commands); + RedisPrimitiveKey key = lists.key("tenant-a", "recent"); + + lists.admit(key, lists.value("one"), Duration.ofSeconds(30)); + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.BOUNDED_LIST_ADMISSION_V1); + lists.trimNewest(key, 10); + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.GUARDED_LIST_TRIM_V1); + assertThat(java.util.Arrays.stream(RedisPrimitiveId.values()).map(Enum::name)) + .noneMatch(name -> name.contains("BLPOP") || name.contains("BRPOP")); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramCatalogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramCatalogTest.java new file mode 100644 index 0000000..3028cfe --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramCatalogTest.java @@ -0,0 +1,177 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveProgramCatalogTest { + + private static final Set REQUIRED = + Set.of( + RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1, + RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1, + RedisProgramId.BOUNDED_SET_ADMISSION_V1, + RedisProgramId.BOUNDED_LIST_ADMISSION_V1, + RedisProgramId.HASH_REVISION_CAS_V1); + + @Test + void primitiveManifestCatalogOwnsRequiredAtomicAndHardBoundPrograms() { + RedisProgramCatalog catalog = RedisProgramCatalog.primitiveAtomic(); + + assertThat(catalog.descriptors()) + .extracting(RedisProgramDescriptor::id) + .containsAll(REQUIRED) + .contains( + RedisProgramId.BOUNDED_HASH_FIELD_ADMISSION_V1, + RedisProgramId.BOUNDED_ZSET_ADMISSION_V1, + RedisProgramId.ZSET_BOUNDED_TRIM_V1, + RedisProgramId.GUARDED_LIST_TRIM_V1, + RedisProgramId.BOUNDED_GEO_ADMISSION_V1, + RedisProgramId.BOUNDED_MGET_V1, + RedisProgramId.BOUNDED_HASH_SCAN_PAGE_V1, + RedisProgramId.BOUNDED_SET_SCAN_PAGE_V1) + .hasSize(13); + assertThat(catalog.descriptors()) + .allSatisfy( + descriptor -> { + assertThat(descriptor.keyCount()).isBetween(1, 4); + assertThat(descriptor.contract().slotRule()) + .isIn("SINGLE_KEY", "SAME_RESOURCE_HASH_TAG"); + assertThat(descriptor.contract().minimumRedisVersion()).isEqualTo("7.2"); + assertThat(descriptor.contract().validateBeforeFirstWrite()).isNotEmpty(); + assertThat(descriptor.contract().aclCommands()) + .contains("EVALSHA", "SCRIPT|LOAD", "TYPE"); + assertThat(descriptor.sha256()).matches("[0-9a-f]{64}"); + assertThat(descriptor.scriptBytes()).isNotEmpty(); + }); + } + + @Test + void scriptsPreflightAclAndAllValidationBeforeTheirFirstWrite() { + RedisProgramCatalog.primitiveAtomic() + .descriptors() + .forEach( + descriptor -> { + String source = new String(descriptor.scriptBytes(), StandardCharsets.UTF_8); + int firstWrite = firstWrite(source); + if (descriptor.contract().timeoutCertainty().equals("READ_ONLY")) { + assertThat(firstWrite).isNegative(); + } else { + assertThat(firstWrite).isPositive(); + assertThat(source.indexOf("redis.acl_check_cmd")).isBetween(0, firstWrite); + assertThat(source.indexOf("TYPE")).isBetween(0, firstWrite); + } + assertThat(source).doesNotContain("EVAL", "EVALSHA", "loadstring"); + }); + } + + @Test + void signed64CounterUsesCanonicalDecimalArithmeticAndNeverConvertsThroughLuaNumber() { + RedisProgramDescriptor increment = + RedisProgramCatalog.primitiveAtomic() + .descriptor(RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1); + String source = new String(increment.scriptBytes(), StandardCharsets.UTF_8); + + assertThat(source).contains("9223372036854775807", "9223372036854775808", "INCRBY"); + assertThat(source).doesNotContain("math.", "tonumber"); + assertThat(increment.statuses()) + .contains( + "UPDATED", + "LIMIT_EXCEEDED", + "OVERFLOW", + "MALFORMED_VALUE", + "MISSING_TTL", + "WRONG_TYPE", + "INVALID"); + } + + @Test + void collectionCreationCompensatesCatchableTtlFailureAndDeclaresExactAcl() { + assertCompensatingTtl( + RedisProgramId.BOUNDED_SET_ADMISSION_V1, + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "SISMEMBER", + "SCARD", + "SADD", + "PEXPIRE", + "DEL")); + assertCompensatingTtl( + RedisProgramId.BOUNDED_LIST_ADMISSION_V1, + Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "PTTL", "LLEN", "RPUSH", "PEXPIRE", "DEL")); + assertCompensatingTtl( + RedisProgramId.HASH_REVISION_CAS_V1, + Set.of( + "EVALSHA", + "SCRIPT|LOAD", + "TYPE", + "PTTL", + "HLEN", + "HEXISTS", + "HGET", + "HSET", + "PEXPIRE", + "DEL")); + } + + @Test + void hashCasRejectsAnExistingHashWithoutItsOwnedRevision() { + RedisProgramDescriptor hashCas = + RedisProgramCatalog.primitiveAtomic().descriptor(RedisProgramId.HASH_REVISION_CAS_V1); + String source = new String(hashCas.scriptBytes(), StandardCharsets.UTF_8); + + assertThat(source) + .contains( + "not missing and not current", + "redis.call('HLEN', key) ~= 2", + "redis.call('HEXISTS', key, 'value') ~= 1", + "MALFORMED_REVISION", + "#ARGV[4] < 1", + "1048448"); + } + + @Test + void primitiveProgramAclInventoryMatchesEveryConditionalCommand() { + RedisProgramCatalog catalog = RedisProgramCatalog.primitiveAtomic(); + + assertThat( + catalog + .descriptor(RedisProgramId.INCREMENT_WITH_INITIAL_TTL_V1) + .contract() + .aclCommands()) + .isEqualTo(Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "GET", "PTTL", "SET", "INCRBY")); + assertThat( + catalog.descriptor(RedisProgramId.COMPARE_AND_SET_WITH_TTL_V1).contract().aclCommands()) + .isEqualTo(Set.of("EVALSHA", "SCRIPT|LOAD", "TYPE", "GET", "SET")); + } + + private static void assertCompensatingTtl(RedisProgramId id, Set expectedAclCommands) { + RedisProgramDescriptor descriptor = RedisProgramCatalog.primitiveAtomic().descriptor(id); + String source = new String(descriptor.scriptBytes(), StandardCharsets.UTF_8); + + assertThat(descriptor.statuses()).contains("TTL_APPLY_FAILED"); + assertThat(descriptor.contract().aclCommands()).isEqualTo(expectedAclCommands); + assertThat(source) + .contains("redis.pcall('PEXPIRE'", "expiry ~= 1", "redis.call('DEL'", "TTL_APPLY_FAILED"); + } + + private static int firstWrite(String source) { + return java.util.stream.Stream.of( + source.indexOf("redis.call('SET'"), + source.indexOf("redis.call('HSET'"), + source.indexOf("redis.call('SADD'"), + source.indexOf("redis.call('RPUSH'"), + source.indexOf("redis.call('ZADD'"), + source.indexOf("redis.call('GEOADD'"), + source.indexOf("redis.call('ZREMRANGEBYSCORE'"), + source.indexOf("redis.call('LTRIM'")) + .filter(index -> index >= 0) + .min(Integer::compareTo) + .orElse(-1); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcherTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcherTest.java new file mode 100644 index 0000000..d262cd2 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveProgramDispatcherTest.java @@ -0,0 +1,211 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveProgramDispatcherTest { + + @Test + void boundedGetUsesCatalogLimitReadOnlyShapeAndPreservesBinaryOrMissing() { + DispatchCommands commands = new DispatchCommands(); + commands.enqueue(RedisCatalogProgramReply.value(new byte[] {0, (byte) 0xff, 1})); + RedisStringValuePrimitives strings = RedisPrimitiveCatalog.standard().strings(commands); + + RedisPrimitiveReply present = strings.get(strings.key("tenant", "binary")); + + assertThat(present.status()).isEqualTo(RedisPrimitiveReply.Status.PRESENT); + assertThat(present.values().getFirst().copyEncoded()).containsExactly(0, (byte) 0xff, 1); + assertThat(commands.lastProgram.replyShape()) + .isEqualTo(RedisCatalogProgramInvocation.ReplyShape.READ_ONLY_VALUE); + assertThat(ascii(RedisCatalogProgramInvocation.WireCodec.argument(commands.lastProgram, 0))) + .isEqualTo("16000"); + + commands.enqueue(RedisCatalogProgramReply.value(null)); + assertThat(strings.get(strings.key("tenant", "missing")).status()) + .isEqualTo(RedisPrimitiveReply.Status.MISSING); + } + + @Test + void mgetTruncatesPaddedKeysToRequestedCountAndPreservesMissingAndBinary() { + DispatchCommands commands = new DispatchCommands(); + commands.enqueue(multi("V1", "OK", packed(new byte[] {0, 1}, null))); + RedisStringValuePrimitives strings = RedisPrimitiveCatalog.standard().strings(commands); + RedisPrimitiveKey first = strings.key("tenant", "one"); + RedisPrimitiveKey second = strings.key("tenant", "two"); + + RedisPrimitiveReply reply = strings.multiGet(List.of(first, second)); + + assertThat(commands.lastProgram.keyCount()).isEqualTo(4); + assertThat(reply.elements()).hasSize(2); + assertThat(reply.elements().get(0).value().orElseThrow().copyEncoded()).containsExactly(0, 1); + assertThat(reply.elements().get(1).value()).isEmpty(); + } + + @Test + void signedCounterResultPreservesNegativeValueAndNoScriptRecoveryUsesSameInvocation() { + DispatchCommands commands = new DispatchCommands(); + commands.enqueue(new RedisNoScriptException()); + commands.enqueue(multi("V1", "UPDATED", "-2")); + RedisCounterPrimitives counters = RedisPrimitiveCatalog.standard().counters(commands); + + RedisCounterResult result = + counters.increment(counters.key("tenant", "counter"), -2, -10, 10, Duration.ofSeconds(30)); + + assertThat(result.status()).isEqualTo(RedisCounterResult.Status.UPDATED); + assertThat(result.value()).hasValue(-2); + assertThat(commands.loads).isOne(); + assertThat(commands.executedPrograms).hasSize(2); + assertThat(commands.executedPrograms.get(0)).isSameAs(commands.executedPrograms.get(1)); + } + + @Test + void scanParsesCursorSeparatelyAndReturnsTypedHashEntries() { + DispatchCommands commands = new DispatchCommands(); + commands.enqueue( + multi( + "V1", + "PAGE", + packed( + "17".getBytes(StandardCharsets.US_ASCII), + "field".getBytes(StandardCharsets.UTF_8), + new byte[] {0, 1, 2}))); + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE); + RedisPrimitiveKey key = hashes.key("tenant", "hash"); + + RedisPrimitiveScanOutcome result = + hashes.scan(key, RedisPrimitiveCursor.initial(catalog, descriptor, key, 9), 9); + + RedisPrimitivePage page = result.page().orElseThrow(); + assertThat(page.nextCursor().rawCursor()).isEqualTo("17"); + assertThat(page.nextCursor().routeEpoch()).isEqualTo(9); + assertThat(page.elements()).hasSize(1); + assertThat(page.elements().getFirst().field().copyEncoded()) + .isEqualTo("field".getBytes(StandardCharsets.UTF_8)); + assertThat(page.elements().getFirst().value().copyEncoded()).containsExactly(0, 1, 2); + } + + @Test + void missingHashAndSetScansDecodeAsCompletedEmptyPages() { + DispatchCommands commands = new DispatchCommands(); + commands.enqueue(multi("V1", "PAGE", packed(asciiBytes("0")))); + commands.enqueue(multi("V1", "PAGE", packed(asciiBytes("0")))); + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisHashPrimitives hashes = catalog.hashes(commands); + RedisSetPrimitives sets = catalog.sets(commands); + RedisPrimitiveKey hashKey = hashes.key("tenant", "missing-hash"); + RedisPrimitiveKey setKey = sets.key("tenant", "missing-set"); + + RedisPrimitivePage hashPage = + hashes + .scan( + hashKey, + RedisPrimitiveCursor.initial( + catalog, catalog.descriptor(RedisPrimitiveId.HASH_SCAN_PAGE), hashKey, 1), + 1) + .page() + .orElseThrow(); + RedisPrimitivePage setPage = + sets.scan( + setKey, + RedisPrimitiveCursor.initial( + catalog, catalog.descriptor(RedisPrimitiveId.SET_SCAN_PAGE), setKey, 1), + 1) + .page() + .orElseThrow(); + + assertThat(hashPage.elements()).isEmpty(); + assertThat(hashPage.nextCursor().rawCursor()).isEqualTo("0"); + assertThat(hashPage.complete()).isTrue(); + assertThat(setPage.elements()).isEmpty(); + assertThat(setPage.nextCursor().rawCursor()).isEqualTo("0"); + assertThat(setPage.complete()).isTrue(); + } + + @Test + void malformedVersionedStatusIsRejectedWithoutFallback() { + DispatchCommands commands = new DispatchCommands(); + commands.enqueue(multi("V1", "NOT_A_STATUS", "0")); + RedisCounterPrimitives counters = RedisPrimitiveCatalog.standard().counters(commands); + + assertThatThrownBy( + () -> + counters.increment( + counters.key("tenant", "counter"), 1, 0, 10, Duration.ofSeconds(30))) + .isInstanceOf(RedisProgramCompatibilityException.class); + } + + private static RedisCatalogProgramReply multi(String version, String status, String detail) { + return RedisCatalogProgramReply.multi( + List.of(asciiBytes(version), asciiBytes(status), asciiBytes(detail))); + } + + private static RedisCatalogProgramReply multi(String version, String status, byte[] detail) { + return RedisCatalogProgramReply.multi(List.of(asciiBytes(version), asciiBytes(status), detail)); + } + + private static byte[] packed(byte[]... values) { + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + for (byte[] value : values) { + byte[] prefix = + (value == null ? "-1:" : value.length + ":").getBytes(StandardCharsets.US_ASCII); + output.writeBytes(prefix); + if (value != null) { + output.writeBytes(value); + } + } + return output.toByteArray(); + } + + private static byte[] asciiBytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static String ascii(byte[] value) { + return new String(value, StandardCharsets.US_ASCII); + } + + private static final class DispatchCommands + implements RedisPrimitiveCommands, RedisStructuredCommands { + + private final ArrayDeque replies = new ArrayDeque<>(); + private final java.util.ArrayList executedPrograms = + new java.util.ArrayList<>(); + private RedisCatalogProgramInvocation lastProgram; + private int loads; + + void enqueue(Object reply) { + replies.add(reply); + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + return new RedisPrimitiveProgramDispatcher(this).execute(invocation); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + loads++; + return invocation.sha1(); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + lastProgram = invocation; + executedPrograms.add(invocation); + Object reply = replies.removeFirst(); + if (reply instanceof RuntimeException failure) { + throw failure; + } + return (RedisCatalogProgramReply) reply; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRouterTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRouterTest.java new file mode 100644 index 0000000..7e73b74 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRouterTest.java @@ -0,0 +1,185 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveRouterTest { + + @Test + void canonical65536RouterAdmitsOneRequestPlusResultLeaseAndRejectsSecondBeforeRuntime() + throws Exception { + BlockingRuntime runtime = new BlockingRuntime(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + RedisRole.CACHE, + runtime, + 2, + 65_536, + 65_536, + Duration.ofSeconds(1), + Duration.ofSeconds(30)); + RedisStringValuePrimitives strings = RedisPrimitiveCatalog.standard().strings(router); + RedisPrimitiveKey key = strings.key("tenant", "one"); + Thread first = Thread.ofVirtual().start(() -> strings.get(key)); + assertThat(runtime.entered.await(2, TimeUnit.SECONDS)).isTrue(); + + assertThatThrownBy(() -> strings.get(key)) + .isInstanceOf(RedisCommandFailureException.class) + .satisfies( + failure -> + assertThat(((RedisCommandFailureException) failure).kind()) + .isEqualTo(RedisCommandFailureException.Kind.OVERLOADED)); + assertThat(runtime.calls).hasValue(1); + + runtime.release.countDown(); + first.join(); + router.close(); + } + + @Test + void wrongRoleIsRejectedBeforeAdmissionOrRuntimeDispatch() { + BlockingRuntime runtime = new BlockingRuntime(); + RedisRoleCommandRouter cacheRouter = + new RedisRoleCommandRouter( + RedisRole.CACHE, + runtime, + 1, + 65_536, + 65_536, + Duration.ofSeconds(1), + Duration.ofSeconds(30)); + RedisCounterPrimitives counters = RedisPrimitiveCatalog.standard().counters(cacheRouter); + + assertThatThrownBy(() -> counters.read(counters.key("tenant", "counter"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("canonical role"); + assertThat(runtime.calls).hasValue(0); + cacheRouter.close(); + } + + @Test + void noScriptRecoveryStaysInsideOneSelectedRuntimeAndConsumesOneDecreasingDeadline() { + RecoveryRuntime runtime = new RecoveryRuntime(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + RedisRole.COORDINATION, + runtime, + 1, + 65_536, + 65_536, + Duration.ofSeconds(1), + Duration.ofSeconds(30)); + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + AtomicLong ticker = new AtomicLong(); + RedisPrimitiveExecutor executor = + new RedisPrimitiveExecutor(catalog, router, () -> ticker.getAndAdd(100_000_000)); + RedisPrimitiveKey key = + catalog.keyFactory(RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL).key("tenant", "counter"); + + RedisPrimitiveReply reply = + executor.execute( + RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL, + List.of(key), + new RedisPrimitiveInvocation.CounterArguments(1, 0, 10, Duration.ofSeconds(30))); + + assertThat(reply.status()).isEqualTo(RedisPrimitiveReply.Status.UPDATED); + assertThat(runtime.primitiveCalls).isOne(); + assertThat(runtime.programCalls).isEqualTo(2); + assertThat(runtime.loads).isOne(); + assertThat(runtime.budgets).isSortedAccordingTo(java.util.Comparator.reverseOrder()); + assertThat(runtime.budgets).doesNotHaveDuplicates(); + router.close(); + } + + private static class BlockingRuntime implements RedisRoutableCommandRuntime { + + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + calls.incrementAndGet(); + entered.countDown(); + try { + release.await(2, TimeUnit.SECONDS); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + return RedisPrimitiveReply.missing(); + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public String deploymentId() { + return "test"; + } + + @Override + public void probe(Duration timeout) {} + + @Override + public void close() { + release.countDown(); + } + } + + private static final class RecoveryRuntime extends BlockingRuntime + implements RedisStructuredCommands { + + private int primitiveCalls; + private int programCalls; + private int loads; + private final java.util.ArrayList budgets = new java.util.ArrayList<>(); + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + primitiveCalls++; + return new RedisPrimitiveProgramDispatcher(this).execute(invocation); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + programCalls++; + budgets.add(invocation.boundedTimeout(Duration.ofSeconds(5))); + if (programCalls == 1) { + throw new RedisNoScriptException(); + } + return RedisCatalogProgramReply.multi(List.of(ascii("V1"), ascii("UPDATED"), ascii("1"))); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + loads++; + budgets.add(invocation.boundedTimeout(Duration.ofSeconds(5))); + return invocation.sha1(); + } + + private static byte[] ascii(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRuntimeServiceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRuntimeServiceTest.java new file mode 100644 index 0000000..e5f3481 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRuntimeServiceTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("redis-service") +class RedisPrimitiveRuntimeServiceTest { + + @Test + void independentLettuceClientsShareAtomicBoundedPrimitivePrograms() throws Exception { + RedisRuntimeSettings settings = settings(); + try (LettuceRedisRuntime firstRuntime = LettuceRedisRuntime.connect(settings); + LettuceRedisRuntime secondRuntime = LettuceRedisRuntime.connect(settings)) { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisCounterPrimitives first = catalog.counters(new ProgramOnlyCommands(firstRuntime)); + RedisCounterPrimitives second = catalog.counters(new ProgramOnlyCommands(secondRuntime)); + RedisPrimitiveKey key = + first.key("service", "counter-" + Long.toUnsignedString(System.nanoTime())); + List> increments = + java.util.stream.IntStream.range(0, 100) + .>mapToObj( + index -> + () -> + (index & 1) == 0 + ? first.increment(key, 1, 0, 100, Duration.ofSeconds(10)) + : second.increment(key, 1, 0, 100, Duration.ofSeconds(10))) + .toList(); + + try (var executor = Executors.newFixedThreadPool(8)) { + List results = + executor.invokeAll(increments).stream() + .map( + future -> { + try { + return future.get(); + } catch (Exception failure) { + throw new AssertionError(failure); + } + }) + .toList(); + assertThat(results) + .allSatisfy( + result -> assertThat(result.status()).isEqualTo(RedisCounterResult.Status.UPDATED)); + assertThat(results.stream().flatMapToLong(result -> result.value().stream()).max()) + .hasValue(100); + } + + RedisCounterResult oneOver = first.increment(key, 1, 0, 100, Duration.ofSeconds(10)); + assertThat(oneOver.status()).isEqualTo(RedisCounterResult.Status.LIMIT_EXCEEDED); + assertThat(oneOver.value()).hasValue(100); + assertThat(firstRuntime.delete(key.physicalKey())).isOne(); + } + } + + private static RedisRuntimeSettings settings() { + byte[] ephemeralHmacMaterial = new byte[32]; + new SecureRandom().nextBytes(ephemeralHmacMaterial); + String encodedHmacMaterial = Base64.getEncoder().encodeToString(ephemeralHmacMaterial); + Arrays.fill(ephemeralHmacMaterial, (byte) 0); + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + requiredProperty("redis.test.host"), + Integer.parseInt(requiredProperty("redis.test.port")), + "", + encodedHmacMaterial, + Duration.ofSeconds(3), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + "ca-skeleton", + "test", + "service", + 16_000); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " must be provided for redis-service tests"); + } + return value; + } + + private record ProgramOnlyCommands(LettuceRedisRuntime delegate) + implements RedisPrimitiveCommands { + + private ProgramOnlyCommands { + java.util.Objects.requireNonNull(delegate, "delegate must be non-null"); + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + if (invocation.descriptor().programId() == null) { + throw new UnsupportedOperationException("service proof accepts catalog programs only"); + } + return new RedisPrimitiveProgramDispatcher(delegate).execute(invocation); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSetTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSetTest.java new file mode 100644 index 0000000..33c7dd1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSetTest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveSetTest { + + @Test + void addIsOnlyAvailableAsDescriptorCapacityAdmission() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisSetPrimitives sets = catalog.sets(commands); + + sets.admit(sets.key("tenant-a", "tags"), sets.member("one"), Duration.ofSeconds(30)); + + RedisPrimitiveInvocation.CapacityArguments arguments = + (RedisPrimitiveInvocation.CapacityArguments) commands.lastInvocation().arguments(); + assertThat(arguments.capacity().value()) + .isEqualTo(catalog.descriptor(RedisPrimitiveId.SET_ADMIT).maximumElements()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSortedSetTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSortedSetTest.java new file mode 100644 index 0000000..58d8104 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSortedSetTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.OptionalLong; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveSortedSetTest { + + @Test + void growthAndTrimAreBothGuardedByOwnedPrograms() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisSortedSetPrimitives sorted = catalog.sortedSets(commands); + RedisPrimitiveKey key = sorted.key("tenant-a", "ranking"); + + sorted.admitOrUpdate( + key, sorted.member("one"), RedisSortedSetScore.of("1.5"), Duration.ofSeconds(30)); + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.BOUNDED_ZSET_ADMISSION_V1); + + sorted.trimBelowOrEqual(key, RedisSortedSetScore.of("0")); + assertThat(commands.lastInvocation().descriptor().programId()) + .isEqualTo(RedisProgramId.ZSET_BOUNDED_TRIM_V1); + } + + @Test + void pairedScoreAndGeoRepliesCountLogicalEntriesInsteadOfFlatWireFields() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + List exactPairs = pairedValues(256, 1); + + for (RedisPrimitiveId id : + List.of(RedisPrimitiveId.ZSET_SCORE_PAGE, RedisPrimitiveId.GEO_SEARCH)) { + RedisPrimitiveDescriptor descriptor = catalog.descriptor(id); + assertThatCode( + () -> + RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PAGE, + exactPairs, + OptionalLong.of(256), + "")) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PAGE, + pairedValues(257, 1), + OptionalLong.of(257), + "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + RedisPrimitiveReply.bounded( + descriptor, + RedisPrimitiveReply.Status.PAGE, + pairedValues(256, 100), + OptionalLong.of(256), + "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aggregate"); + } + } + + private static List pairedValues(int logicalEntries, int bytesPerField) { + byte[] value = new byte[bytesPerField]; + java.util.Arrays.fill(value, (byte) 'x'); + return java.util.stream.IntStream.range(0, logicalEntries * 2) + .mapToObj(ignored -> RedisPrimitiveValue.copyOf(value, 16_000)) + .toList(); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStringValueTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStringValueTest.java new file mode 100644 index 0000000..8e63fd9 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveStringValueTest.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveStringValueTest { + + @Test + void facadeBindsStringKeysValuesTtlAndClosedOperationIdentity() { + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveTestCommands commands = new RedisPrimitiveTestCommands(); + RedisStringValuePrimitives strings = catalog.strings(commands); + RedisPrimitiveKey key = strings.key("tenant-a", "profile"); + + RedisPrimitiveMutationResult result = + strings.setIfAbsent(key, strings.value("payload"), Duration.ofSeconds(30)); + + assertThat(result.status()).isEqualTo(RedisPrimitiveMutationResult.Status.APPLIED); + assertThat(commands.lastInvocation().descriptor().id()) + .isEqualTo(RedisPrimitiveId.STRING_SET_NX_PX); + assertThat(commands.lastInvocation().arguments()) + .isInstanceOf(RedisPrimitiveInvocation.ExpiringWrite.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSurfaceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSurfaceTest.java new file mode 100644 index 0000000..2f27311 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveSurfaceTest.java @@ -0,0 +1,214 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisPrimitiveSurfaceTest { + + @Test + void commandPortsDoNotAcceptRawProgramIdentitySourceOrKeyContainers() { + for (Class commandPort : List.of(RedisStructuredCommands.class, RedisBinaryCommands.class)) { + assertThat(Arrays.stream(commandPort.getDeclaredMethods()).map(Method::getName)) + .doesNotContain("scriptLoad", "evalSha", "evalShaReadOnly", "evalShaMulti"); + assertThat( + Arrays.stream(commandPort.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getParameterTypes()))) + .doesNotContain(byte[].class, String.class); + } + } + + @Test + void catalogInvocationAndPhysicalKeyAreOpaquePackagePrivateTypes() throws Exception { + for (String simpleName : + List.of( + "RedisCatalogProgramInvocation", + "RedisPhysicalKey", + "RedisPrimitiveKey", + "RedisPrimitiveKeyFactory", + "RedisPrimitiveValue")) { + Class type = Class.forName(getClass().getPackageName() + "." + simpleName); + assertThat(Modifier.isPublic(type.getModifiers())).isFalse(); + assertThat(type.getDeclaredConstructors()) + .allSatisfy( + constructor -> + assertThat(Modifier.isPrivate(constructor.getModifiers())) + .as(simpleName + " constructor") + .isTrue()); + } + } + + @Test + void primitiveFacadesAndClosedCommandsExposeNoPublicSpringOrRawSurface() { + List> facades = + List.of( + RedisStringValuePrimitives.class, + RedisCounterPrimitives.class, + RedisHashPrimitives.class, + RedisSetPrimitives.class, + RedisSortedSetPrimitives.class, + RedisListPrimitives.class, + RedisBitmapPrimitives.class, + RedisHyperLogLogPrimitives.class, + RedisGeoPrimitives.class); + assertThat(facades) + .allSatisfy( + facade -> { + assertThat(Modifier.isPublic(facade.getModifiers())).isFalse(); + assertThat(Modifier.isFinal(facade.getModifiers())).isTrue(); + assertThat(facade.getAnnotations()).isEmpty(); + assertThat(Arrays.stream(facade.getDeclaredMethods())) + .allSatisfy( + method -> { + assertThat(Modifier.isPublic(method.getModifiers())).isFalse(); + assertThat(Arrays.asList(method.getParameterTypes())) + .doesNotContain(byte[].class); + }); + }); + + assertThat(RedisPrimitiveCommands.class.getDeclaredMethods()) + .singleElement() + .satisfies( + method -> { + assertThat(method.getName()).isEqualTo("execute"); + assertThat(method.getParameterTypes()) + .containsExactly(RedisPrimitiveInvocation.class); + }); + } + + @Test + void opaqueInvocationKeyAndFactoriesHaveNoNonPrivateRawArrayInputOrOutput() { + for (Class type : + List.of( + RedisCatalogProgramInvocation.class, + RedisPhysicalKey.class, + RedisPrimitiveKey.class, + RedisPrimitiveKeyFactory.class)) { + assertThat(Arrays.stream(type.getDeclaredMethods())) + .filteredOn(method -> !Modifier.isPrivate(method.getModifiers())) + .allSatisfy( + method -> { + assertThat(method.getReturnType()) + .as(type.getSimpleName() + "." + method.getName() + " return") + .isNotEqualTo(byte[].class) + .isNotEqualTo(byte[][].class); + assertThat(Arrays.asList(method.getParameterTypes())) + .as(type.getSimpleName() + "." + method.getName() + " parameters") + .doesNotContain(byte[].class, byte[][].class); + assertThat(method.getGenericParameterTypes()) + .allSatisfy( + parameter -> assertThat(parameter.getTypeName()).doesNotContain("byte[]")); + }); + } + + assertThat(RedisCatalogProgramInvocation.WireCodec.class.getDeclaredMethods()) + .allSatisfy( + method -> { + assertThat(Arrays.asList(method.getParameterTypes())) + .contains(RedisCatalogProgramInvocation.class) + .doesNotContain(String.class); + assertThat(Modifier.isPublic(method.getModifiers())).isFalse(); + }); + } + + @Test + void everyProgramExecutionSeamAndNestedFactoryRejectsCallerSuppliedRawMaterial() { + List> roots = + List.of( + RedisProgramExecutor.class, + RedisRateProgramExecutor.class, + RedisLuaProgramExecutor.class, + RedisStructuredProgramExecutor.class, + RedisLeaseProgramExecutor.class, + RedisIdempotencyProgramExecutor.class, + RedisProgramCatalog.class, + RedisCatalogProgramInvocation.class, + RedisPhysicalKey.class, + RedisSemanticAclProbeCatalog.class, + RedisSemanticReadinessProbe.class, + RedisCatalogProgramMaterial.class, + RedisOwnedPhysicalKeyMaterial.class); + List> surfaces = new ArrayList<>(); + roots.forEach(type -> collectNested(type, surfaces)); + + assertThat(surfaces) + .filteredOn(type -> !type.getSimpleName().equals("WireCodec")) + .allSatisfy( + type -> + assertThat(Arrays.stream(type.getDeclaredMethods())) + .filteredOn(method -> !Modifier.isPrivate(method.getModifiers())) + .allSatisfy( + method -> { + assertThat(method.getGenericParameterTypes()) + .as(type.getSimpleName() + "." + method.getName()) + .allSatisfy( + parameter -> + assertThat(parameter.getTypeName()) + .doesNotContain( + "byte[]", + "java.util.List", + "java.util.List")); + if (Modifier.isStatic(method.getModifiers())) { + assertThat(Arrays.asList(method.getParameterTypes())) + .doesNotContain(Object.class); + } + })); + } + + @Test + void sealedOwnerMaterialsPermitExactlyPrivateConstructedSemanticOwners() throws Exception { + assertExactPrivatePermits( + RedisCatalogProgramMaterial.class, + Set.of( + RedisAtomicPrimitives.ProgramMaterial.class, + RedisEdgeRateLimitProvider.ProgramInvocation.class, + RedisEfficiencyLeaseProvider.ProgramInvocation.class, + RedisEfficiencyLeaseHandle.ProgramInvocation.class, + RedisIdempotencyStoreProvider.ProgramInvocation.class, + RedisLuaVersionedSessionStore.ProgramInvocation.class, + RedisSemanticReadinessProbe.ProgramInvocation.class)); + assertExactPrivatePermits( + RedisOwnedPhysicalKeyMaterial.class, + Set.of( + LettuceRedisRuntime.LegacyKeyMaterial.class, + RedisRoleCommandRouter.LegacyKeyMaterial.class, + RedisStringCacheRegion.CacheKeyMaterial.class, + RedisCacheConsistencyStore.ConsistencyKeyMaterial.class, + RedisSemanticReadinessProbe.ProbeKeyMaterial.class)); + + Class readinessAclMaterial = + Class.forName( + getClass().getPackageName() + ".RedisSemanticReadinessProbe$AclProbeMaterial"); + assertThat(readinessAclMaterial.getDeclaredConstructors()) + .isNotEmpty() + .allSatisfy( + constructor -> assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue()); + } + + private static void assertExactPrivatePermits( + Class sealedMaterial, Set> expectedPermits) { + assertThat(sealedMaterial.isSealed()).isTrue(); + assertThat(Set.of(sealedMaterial.getPermittedSubclasses())).isEqualTo(expectedPermits); + assertThat(expectedPermits) + .allSatisfy( + owner -> + assertThat(owner.getDeclaredConstructors()) + .isNotEmpty() + .allSatisfy( + constructor -> + assertThat(Modifier.isPrivate(constructor.getModifiers())) + .as(owner.getName() + " constructor") + .isTrue())); + } + + private static void collectNested(Class type, List> sink) { + sink.add(type); + Arrays.stream(type.getDeclaredClasses()).forEach(nested -> collectNested(nested, sink)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveTestCommands.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveTestCommands.java new file mode 100644 index 0000000..a04762b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveTestCommands.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +final class RedisPrimitiveTestCommands implements RedisPrimitiveCommands { + + private RedisPrimitiveInvocation lastInvocation; + private final java.util.List invocations = new java.util.ArrayList<>(); + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + lastInvocation = invocation; + invocations.add(invocation); + if (invocation.descriptor().id() == RedisPrimitiveId.HASH_SCAN_PAGE) { + RedisPrimitiveCursor cursor = + ((RedisPrimitiveInvocation.ScanPageArguments) invocation.arguments()).cursor(); + return RedisPrimitiveReply.page( + invocation.descriptor(), + RedisPrimitivePage.bounded( + invocation.descriptor(), + java.util.List.of(), + cursor.advance("0"), + 0)); + } + if (invocation.descriptor().id() == RedisPrimitiveId.SET_SCAN_PAGE) { + RedisPrimitiveCursor cursor = + ((RedisPrimitiveInvocation.ScanPageArguments) invocation.arguments()).cursor(); + return RedisPrimitiveReply.page( + invocation.descriptor(), + RedisPrimitivePage.bounded( + invocation.descriptor(), + java.util.List.of(), + cursor.advance("0"), + 0)); + } + return RedisPrimitiveReply.applied(1, null); + } + + RedisPrimitiveInvocation lastInvocation() { + return lastInvocation; + } + + java.util.List invocations() { + return java.util.List.copyOf(invocations); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java index 283425a..4ed2bff 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramCatalogTest.java @@ -17,12 +17,21 @@ class RedisProgramCatalogTest { void loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature() { RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); - assertThat(catalog.descriptors()).hasSize(3); + assertThat(catalog.descriptors()).hasSize(8); + assertThat(catalog.descriptor(RedisProgramId.BOUNDED_GET_V1).maximumReplyFieldBytes()) + .isEqualTo(16_777_216); assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).keyCount()).isEqualTo(1); assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_DELETE).argumentCount()).isEqualTo(1); assertThat(catalog.descriptor(RedisProgramId.COMPARE_AND_EXPIRE).argumentCount()).isEqualTo(2); assertThat(catalog.descriptor(RedisProgramId.SET_IF_ABSENT_WITH_TTL).argumentCount()) .isEqualTo(3); + assertThat(catalog.descriptor(RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL).argumentCount()) + .isEqualTo(4); + assertThat(catalog.descriptor(RedisProgramId.REGION_GENERATION_INIT).argumentCount()) + .isEqualTo(2); + assertThat(catalog.descriptor(RedisProgramId.REGION_GENERATION_BUMP).argumentCount()) + .isEqualTo(3); + assertThat(catalog.descriptor(RedisProgramId.CACHE_REFRESH_CLAIM).argumentCount()).isEqualTo(3); catalog .descriptors() @@ -45,6 +54,16 @@ class RedisProgramCatalogTest { assertThat(descriptor.scriptBytes()[0]).isNotZero(); } + @Test + void observedReplacementReadsOnlyTheBoundedTrailingDigestInsideLua() { + RedisProgramDescriptor descriptor = + RedisProgramCatalog.foundation().descriptor(RedisProgramId.REPLACE_IF_OBSERVED_WITH_TTL); + String script = new String(descriptor.scriptBytes(), StandardCharsets.UTF_8); + + assertThat(script).contains("redis.call('GETRANGE', KEYS[1], -32, -1)"); + assertThat(script).doesNotContain("redis.call('GET', KEYS[1])"); + } + @Test void machineReadableManifestMatchesTheCompiledCatalog() throws IOException { String manifest; @@ -56,7 +75,15 @@ class RedisProgramCatalogTest { manifest = new String(input.readAllBytes(), StandardCharsets.UTF_8); } + assertThat(JsonPath.read(manifest, "$.minimumRedisVersion")).isEqualTo("7.2"); assertThat(JsonPath.read(manifest, "$.readiness")).isEqualTo("R0"); + assertThat(JsonPath.read(manifest, "$.semanticProviders.cacheRefresh.claimProgram")) + .isEqualTo("cache-refresh-claim-v1"); + assertThat(JsonPath.read(manifest, "$.semanticProviders.cacheRefresh.releaseProgram")) + .isEqualTo("compare-and-delete-v1"); + assertThat(JsonPath.read(manifest, "$.semanticProviders.cacheRefresh.guarantee")) + .contains("not a business correctness lock") + .contains("no TTL renewal"); List> programs = JsonPath.read(manifest, "$.programs"); RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); assertThat(programs).hasSameSizeAs(catalog.descriptors()); diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramManifestContractTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramManifestContractTest.java new file mode 100644 index 0000000..893ab9c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramManifestContractTest.java @@ -0,0 +1,244 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisProgramManifestContractTest { + + private static final List REQUIRED_PROGRAM_FIELDS = + List.of( + "id", + "semanticVersion", + "libraryName", + "registeredFunctionName", + "scriptResource", + "sha256", + "keyCount", + "argumentCount", + "replyFieldCount", + "keys", + "arguments", + "resultSchema", + "slotRule", + "state", + "ttl", + "validateBeforeFirstWrite", + "statuses", + "complexity", + "maximumIterations", + "stateGrowth", + "clock", + "minimumRedisVersion", + "retrySafety", + "timeoutCertainty", + "aclCommands"); + + @Test + void schemaRequiresTheCompleteOperationalProgramContract() throws IOException { + String schema = Files.readString(schemaPath()); + + assertThat(JsonPath.read(schema, "$.properties.schemaVersion.const").intValue()) + .isEqualTo(1); + assertThat(JsonPath.>read(schema, "$['$defs'].program.required")) + .containsExactlyElementsOf(REQUIRED_PROGRAM_FIELDS); + assertThat(JsonPath.read(schema, "$['$defs'].program.additionalProperties")).isFalse(); + } + + @Test + void everyClosedProgramManifestExactlyMatchesTheUnifiedRuntimeCatalog() throws IOException { + List manifests = + List.of( + new ManifestCase("redis/program-set.json", RedisProgramCatalog.foundation()), + new ManifestCase( + "redis/primitive-program-set.json", RedisProgramCatalog.primitiveAtomic()), + new ManifestCase("redis/rate-program-set.json", RedisProgramCatalog.rateLimit()), + new ManifestCase( + "redis/idempotency-program-set.json", RedisProgramCatalog.idempotencyV2()), + new ManifestCase("redis/lease-program-set.json", RedisProgramCatalog.efficiencyLease()), + new ManifestCase("redis/session-program-set.json", RedisProgramCatalog.sessionV1())); + Set manifestIds = new HashSet<>(); + + for (ManifestCase manifest : manifests) { + validate(manifest, manifestIds); + } + + RedisProgramCatalog unified = RedisProgramCatalog.unified(); + assertThat(manifestIds).containsExactlyInAnyOrder(RedisProgramId.values()); + assertThat(unified.descriptors()).hasSize(RedisProgramId.values().length); + assertThat(unified.descriptors()) + .extracting(RedisProgramDescriptor::id) + .containsExactlyInAnyOrderElementsOf(manifestIds); + } + + @Test + void commandPortsExposeOnlyCatalogOwnedTypedProgramExecution() { + Set methodNames = new HashSet<>(); + for (Class commandPort : List.of(RedisBinaryCommands.class, RedisStructuredCommands.class)) { + for (Method method : commandPort.getDeclaredMethods()) { + methodNames.add(method.getName()); + } + } + + assertThat(methodNames) + .containsExactlyInAnyOrder( + "get", "set", "delete", "executeCatalogProgram", "loadCatalogProgram"); + assertThat(methodNames) + .doesNotContain("eval", "evalMulti", "evalSha", "evalShaMulti", "scriptLoad"); + } + + private static void validate(ManifestCase manifestCase, Set manifestIds) + throws IOException { + String manifest = resource(manifestCase.resource()); + assertThat(JsonPath.read(manifest, "$.schemaVersion").intValue()).isEqualTo(1); + String minimumRedisVersion = JsonPath.read(manifest, "$.minimumRedisVersion"); + assertThat(minimumRedisVersion).isEqualTo("7.2"); + List> programs = JsonPath.read(manifest, "$.programs"); + assertThat(programs).hasSameSizeAs(manifestCase.catalog().descriptors()); + + for (Map program : programs) { + RedisProgramDescriptor descriptor = + manifestCase.catalog().descriptors().stream() + .filter(candidate -> candidate.id().externalId().equals(program.get("id"))) + .findFirst() + .orElseThrow(); + assertThat(manifestIds.add(descriptor.id())) + .as("unique program id %s", descriptor.id()) + .isTrue(); + assertProgram(program, descriptor, minimumRedisVersion); + } + } + + private static void assertProgram( + Map program, + RedisProgramDescriptor descriptor, + String manifestMinimumRedisVersion) { + RedisProgramContract contract = descriptor.contract(); + + assertThat(program.keySet()).containsExactlyInAnyOrderElementsOf(REQUIRED_PROGRAM_FIELDS); + assertThat(program.get("id")).isEqualTo(descriptor.id().externalId()); + assertThat(program.get("semanticVersion")).isEqualTo(contract.semanticVersion()); + assertThat(program.get("libraryName")).isEqualTo(contract.libraryName()); + assertThat(program.get("registeredFunctionName")).isEqualTo(contract.registeredFunctionName()); + assertThat(program.get("scriptResource")).isEqualTo(descriptor.id().scriptResource()); + assertThat(program.get("sha256")).isEqualTo(descriptor.sha256()); + assertThat(number(program, "keyCount")).isEqualTo(descriptor.keyCount()); + assertThat(number(program, "argumentCount")).isEqualTo(descriptor.argumentCount()); + assertThat(number(program, "replyFieldCount")).isEqualTo(descriptor.replyFieldCount()); + assertInputs(program, "keys", contract.keys()); + assertInputs(program, "arguments", contract.arguments()); + assertResultSchema(map(program, "resultSchema"), contract.resultSchema()); + assertThat(program.get("slotRule")).isEqualTo(contract.slotRule()); + assertState(map(program, "state"), contract.state()); + assertTtl(map(program, "ttl"), contract.ttl()); + assertThat(strings(program, "validateBeforeFirstWrite")) + .containsExactlyElementsOf(contract.validateBeforeFirstWrite()); + assertThat(Set.copyOf(strings(program, "statuses"))).isEqualTo(descriptor.statuses()); + assertThat(program.get("complexity")).isEqualTo(contract.complexity()); + assertThat(number(program, "maximumIterations")).isEqualTo(contract.maximumIterations()); + assertThat(program.get("stateGrowth")).isEqualTo(contract.stateGrowth()); + assertThat(program.get("clock")).isEqualTo(contract.clock()); + assertThat(program.get("minimumRedisVersion")) + .isEqualTo(manifestMinimumRedisVersion) + .isEqualTo(contract.minimumRedisVersion()); + assertThat(program.get("retrySafety")).isEqualTo(contract.retrySafety()); + assertThat(program.get("timeoutCertainty")).isEqualTo(contract.timeoutCertainty()); + assertThat(Set.copyOf(strings(program, "aclCommands"))).isEqualTo(contract.aclCommands()); + } + + private static void assertInputs( + Map program, String field, List expected) { + List> actual = maps(program, field); + assertThat(actual).hasSameSizeAs(expected); + for (int index = 0; index < expected.size(); index++) { + Map input = actual.get(index); + RedisProgramContract.Input contract = expected.get(index); + assertThat(number(input, "index")).isEqualTo(contract.index()); + assertThat(input.get("name")).isEqualTo(contract.name()); + assertThat(input.get("type")).isEqualTo(contract.type()); + assertThat(number(input, "maximumBytes")).isEqualTo(contract.maximumBytes()); + assertThat((String) input.getOrDefault("sameSlotGroup", "")) + .isEqualTo(contract.sameSlotGroup()); + } + } + + private static void assertResultSchema( + Map actual, RedisProgramContract.ResultSchema expected) { + assertThat(number(actual, "version")).isEqualTo(expected.version()); + assertThat(number(actual, "fieldCount")).isEqualTo(expected.fieldCount()); + assertThat(number(actual, "maximumFieldBytes")).isEqualTo(expected.maximumFieldBytes()); + assertThat(strings(actual, "orderedFields")) + .containsExactlyElementsOf(expected.orderedFields()); + } + + private static void assertState( + Map actual, RedisProgramContract.StateBound expected) { + assertThat(actual.get("type")).isEqualTo(expected.type()); + assertThat(number(actual, "maximumBytes")).isEqualTo(expected.maximumBytes()); + assertThat(number(actual, "maximumEntries")).isEqualTo(expected.maximumEntries()); + } + + private static void assertTtl( + Map actual, RedisProgramContract.TtlBound expected) { + assertThat(actual.get("mode")).isEqualTo(expected.mode()); + assertThat(longNumber(actual, "minimumMillis")).isEqualTo(expected.minimumMillis()); + assertThat(longNumber(actual, "maximumMillis")).isEqualTo(expected.maximumMillis()); + } + + private static String resource(String name) throws IOException { + try (InputStream input = + RedisProgramManifestContractTest.class.getClassLoader().getResourceAsStream(name)) { + assertThat(input).as("manifest resource %s", name).isNotNull(); + return new String(input.readAllBytes(), UTF_8); + } + } + + private static Path schemaPath() { + for (Path directory = Path.of("").toAbsolutePath(); + directory != null; + directory = directory.getParent()) { + Path candidate = directory.resolve("config/redis/program-set.schema.json"); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + throw new IllegalStateException("canonical Redis program-set schema is missing"); + } + + @SuppressWarnings("unchecked") + private static Map map(Map source, String field) { + return (Map) source.get(field); + } + + @SuppressWarnings("unchecked") + private static List> maps(Map source, String field) { + return new ArrayList<>((List>) source.get(field)); + } + + @SuppressWarnings("unchecked") + private static List strings(Map source, String field) { + return (List) source.get(field); + } + + private static int number(Map source, String field) { + return ((Number) source.get(field)).intValue(); + } + + private static long longNumber(Map source, String field) { + return ((Number) source.get(field)).longValue(); + } + + private record ManifestCase(String resource, RedisProgramCatalog catalog) {} +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java new file mode 100644 index 0000000..d6d5c40 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisProgramTestInvocations.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.lang.reflect.Constructor; +import java.util.List; + +/** Test-only reflective access to closed capability material for executor contract fixtures. */ +final class RedisProgramTestInvocations { + + private RedisProgramTestInvocations() {} + + static RedisCatalogProgramInvocation scalar( + RedisProgramCatalog catalog, RedisProgramId id, List keys, List arguments) { + return catalog.capabilityInvocation( + construct(RedisAtomicPrimitives.ProgramMaterial.class, id, keys, arguments)); + } + + static RedisCatalogProgramInvocation structured( + RedisProgramCatalog catalog, RedisProgramId id, List keys, List arguments) { + return catalog.capabilityInvocation( + construct(RedisEdgeRateLimitProvider.ProgramInvocation.class, id, keys, arguments)); + } + + static RedisEfficiencyLeaseProvider.ProgramInvocation lease( + RedisProgramId id, byte[] key, List arguments) { + return constructSingleKey( + RedisEfficiencyLeaseProvider.ProgramInvocation.class, id, key, arguments); + } + + static RedisIdempotencyStoreProvider.ProgramInvocation idempotency( + RedisProgramId id, byte[] key, List arguments) { + return constructSingleKey( + RedisIdempotencyStoreProvider.ProgramInvocation.class, id, key, arguments); + } + + private static T construct( + Class type, RedisProgramId id, List keys, List arguments) { + try { + Constructor constructor = + type.getDeclaredConstructor(RedisProgramId.class, List.class, List.class); + constructor.setAccessible(true); + return constructor.newInstance(id, keys, arguments); + } catch (ReflectiveOperationException failure) { + throw new LinkageError("cannot construct closed test program material", failure); + } + } + + private static T constructSingleKey( + Class type, RedisProgramId id, byte[] key, List arguments) { + try { + Constructor constructor = + type.getDeclaredConstructor(RedisProgramId.class, byte[].class, List.class); + constructor.setAccessible(true); + return constructor.newInstance(id, key, arguments); + } catch (ReflectiveOperationException failure) { + throw new LinkageError("cannot construct closed single-key test material", failure); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfigTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfigTest.java new file mode 100644 index 0000000..dcb5ac2 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitConfigTest.java @@ -0,0 +1,212 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +class RedisRateLimitConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration(RedisRateLimitConfig.class); + + @Test + void disabledByDefaultCreatesNoConnectionRuntimeOrSemanticPort() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(RedisRateLimitRuntime.class)).isEmpty(); + assertThat(context.getBeansOfType(EdgeRateLimitPort.class)).isEmpty(); + assertThat(context.containsBean("distributedRateLimiter")).isFalse(); + }); + } + + @Test + void legacyTransportEnableDoesNotActivateRedisOrResolveMaterial() { + runner + .withPropertyValues("app.rate-limit.enabled=true") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(EdgeRateLimitPort.class)).isEmpty(); + assertThat(context.containsBean("distributedRateLimiter")).isFalse(); + }); + } + + @Test + void canonicalRedisSelectionWithoutCoordinationRoleAndMaterialFailsBeforeConnecting() { + runner + .withPropertyValues("ca-skeleton.capabilities.rate-limit.provider=redis") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class); + assertThat(context.getStartupFailure().getMessage()).doesNotContain("localhost:6379"); + }); + } + + @Test + void canonicalRedisSelectionUsesOnlyTheCoordinationRole() { + AtomicInteger resolutions = new AtomicInteger(); + byte[] decoded = new byte[32]; + Arrays.fill(decoded, (byte) 7); + char[] encoded = Base64.getEncoder().encodeToString(decoded).toCharArray(); + Arrays.fill(decoded, (byte) 0); + + runner + .withBean(RedisProviderSettings.class, () -> new RedisProviderSettings(Map.of(), Map.of())) + .withBean(RedisCanonicalRoleRegistry.class, RedisRateLimitConfigTest::registry) + .withBean( + RedisCredentialMaterialProvider.class, + () -> + reference -> { + resolutions.incrementAndGet(); + return new VersionedRedisCredentialMaterial( + "rate-limit-hmac-v1", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from(encoded)); + }) + .withPropertyValues( + "ca-skeleton.capabilities.rate-limit.provider=redis", + "ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference=secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.rate-limit.namespace-environment=test", + "ca-skeleton.capabilities.rate-limit.policies.api-default.revision=r1", + "ca-skeleton.capabilities.rate-limit.policies.api-default.algorithm=fixed-window", + "ca-skeleton.capabilities.rate-limit.policies.api-default.limit=100", + "ca-skeleton.capabilities.rate-limit.policies.api-default.window=1s", + "ca-skeleton.capabilities.rate-limit.policies.api-default.maximum-cost=10", + "ca-skeleton.capabilities.rate-limit.policies.api-default.cleanup-grace=5s", + "ca-skeleton.capabilities.rate-limit.policies.api-default.maximum-clock-regression=250ms") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(EdgeRateLimitPort.class); + assertThat(context).hasBean("distributedRateLimiter"); + assertThat(resolutions).hasValue(1); + }); + + Arrays.fill(encoded, '\0'); + } + + @Test + void distributedProviderBeanDeclaresSecretDestroyLifecycle() { + var beanMethod = + Arrays.stream(RedisRateLimitConfig.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("distributedRateLimiter")) + .findFirst() + .orElseThrow(() -> new AssertionError("distributed rate limiter bean method missing")); + Bean bean = beanMethod.getAnnotation(Bean.class); + ConditionalOnProperty activation = beanMethod.getAnnotation(ConditionalOnProperty.class); + + assertThat(bean).isNotNull(); + assertThat(bean.destroyMethod()).isEqualTo("close"); + assertThat(activation).isNotNull(); + assertThat(activation.name()).containsExactly("ca-skeleton.capabilities.rate-limit.provider"); + assertThat(activation.havingValue()).isEqualTo("redis"); + assertThat(activation.matchIfMissing()).isFalse(); + } + + private static RedisCanonicalRoleRegistry registry() { + RedisClientRuntimeSettings clientSettings = + new RedisClientRuntimeSettings( + "rate-limit-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + RedisDeploymentSettings.Standalone deployment = + new RedisDeploymentSettings.Standalone( + "coordination-main", + 0, + List.of(new RedisDeploymentSettings.Endpoint("coordination.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "coordination-runtime", "secret://environment/COORDINATION_REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls( + true, true, "secret://environment/COORDINATION_REDIS_TRUST_PEM")); + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, deployment), + clientSettings, + 8, + 65_536, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + ignored -> new NoOpRuntime()); + } + + private static final class NoOpRuntime implements RedisRoutableCommandRuntime { + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return "coordination-main"; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return invocation.replyShape() == RedisCatalogProgramInvocation.ReplyShape.MULTI + ? RedisCatalogProgramReply.multi(List.of()) + : RedisCatalogProgramReply.value(null); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 0; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + return () -> {}; + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettingsTest.java new file mode 100644 index 0000000..982de4f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateLimitSettingsTest.java @@ -0,0 +1,179 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class RedisRateLimitSettingsTest { + + @Test + void disabledSettingsNeedNoEndpointSecretOrPolicy() { + RedisRateLimitSettings settings = + new RedisRateLimitSettings(null, null, null, null, 0, 0, null, null, null, null); + + assertThat(settings.provider()).isEqualTo(RedisRateLimitSettings.Provider.DISABLED); + assertThat(settings.policies()).isEmpty(); + assertThat(settings.keyHmacSecretReference()).isEmpty(); + } + + @Test + void compilesAllThreeExactAlgorithmsAndKeepsTheCoordinationProfileSeparate() { + Map definitions = new LinkedHashMap<>(); + definitions.put( + "fixed", + policy(RateLimitAlgorithm.FIXED_WINDOW, 100L, Duration.ofSeconds(1), null, null, null)); + definitions.put( + "sliding", + policy(RateLimitAlgorithm.SLIDING_COUNTER, 200L, Duration.ofSeconds(2), null, null, null)); + definitions.put( + "tokens", + policy(RateLimitAlgorithm.TOKEN_BUCKET, null, null, 50L, 5L, Duration.ofMillis(250))); + RedisRateLimitSettings settings = enabledSettings("fixed", definitions); + + Map compiled = settings.compiledPolicies(); + + assertThat(compiled.get("fixed").parameters()) + .isEqualTo(new RateParameters.FixedWindow(100, Duration.ofSeconds(1))); + assertThat(compiled.get("sliding").parameters()) + .isEqualTo(new RateParameters.SlidingCounter(200, Duration.ofSeconds(2))); + assertThat(compiled.get("tokens").parameters()) + .isEqualTo(new RateParameters.TokenBucket(50, 5, Duration.ofMillis(250))); + assertThat(settings.namespaceApplication()).isEqualTo("ca-skeleton"); + assertThat(settings.namespaceEnvironment()).isEqualTo("test"); + } + + @Test + void springBinderBuildsAnEnabledExactTokenBucketPolicy() { + Map properties = new LinkedHashMap<>(); + String prefix = "ca-skeleton.capabilities.rate-limit."; + properties.put(prefix + "provider", "redis"); + properties.put(prefix + "failure-policy", "fail-closed"); + properties.put(prefix + "default-policy-id", "api-default"); + properties.put( + prefix + "key-hmac-secret-reference", + "secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET"); + properties.put(prefix + "namespace-application", "ca-skeleton"); + properties.put(prefix + "namespace-environment", "test"); + properties.put(prefix + "policies.api-default.revision", "r1"); + properties.put(prefix + "policies.api-default.algorithm", "token-bucket"); + properties.put(prefix + "policies.api-default.capacity", "100"); + properties.put(prefix + "policies.api-default.refill-tokens", "10"); + properties.put(prefix + "policies.api-default.refill-period", "1s"); + properties.put(prefix + "policies.api-default.maximum-cost", "10"); + properties.put(prefix + "policies.api-default.cleanup-grace", "5s"); + properties.put(prefix + "policies.api-default.maximum-clock-regression", "250ms"); + properties.put(prefix + "policies.api-default.evaluation-dedup-enabled", "true"); + properties.put(prefix + "policies.api-default.evaluation-dedup-ttl", "3s"); + properties.put(prefix + "policies.api-default.evaluation-dedup-maximum-entries", "32"); + properties.put(prefix + "policies.api-default.evaluation-dedup-maximum-stored-bytes", "8192"); + + RedisRateLimitSettings settings = + new Binder(new MapConfigurationPropertySource(properties)) + .bind("ca-skeleton.capabilities.rate-limit", Bindable.of(RedisRateLimitSettings.class)) + .orElseThrow(() -> new AssertionError("rate-limit settings did not bind")); + + assertThat(settings.provider()).isEqualTo(RedisRateLimitSettings.Provider.REDIS); + assertThat(settings.keyHmacSecretReference()) + .isEqualTo("secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET"); + assertThat(settings.compiledPolicies().get("api-default").parameters()) + .isEqualTo(new RateParameters.TokenBucket(100, 10, Duration.ofSeconds(1))); + assertThat(settings.compiledPolicies().get("api-default").evaluationDedupPolicy().timeToLive()) + .isEqualTo(Duration.ofSeconds(3)); + assertThat( + settings.compiledPolicies().get("api-default").evaluationDedupPolicy().maximumEntries()) + .isEqualTo(32); + } + + @Test + void enabledSettingsFailClosedOnMissingMaterialReferenceOrDefaultPolicy() { + assertThatThrownBy( + () -> + new RedisRateLimitSettings( + RedisRateLimitSettings.Provider.REDIS, + RateLimitFailurePolicy.FAIL_CLOSED, + "api-default", + Duration.ofMillis(100), + 1, + 1, + null, + "ca-skeleton", + "test", + Map.of( + "api-default", + policy( + RateLimitAlgorithm.FIXED_WINDOW, + 100L, + Duration.ofSeconds(1), + null, + null, + null)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("secret://"); + + assertThatThrownBy( + () -> + enabledSettings( + "missing", + Map.of( + "api-default", + policy( + RateLimitAlgorithm.FIXED_WINDOW, + 100L, + Duration.ofSeconds(1), + null, + null, + null)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("default-policy"); + } + + private static RedisRateLimitSettings enabledSettings( + String defaultPolicyId, Map definitions) { + return new RedisRateLimitSettings( + RedisRateLimitSettings.Provider.REDIS, + RateLimitFailurePolicy.FAIL_CLOSED, + defaultPolicyId, + Duration.ofMillis(100), + 1, + 1, + "secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET", + "ca-skeleton", + "test", + definitions); + } + + private static RedisRateLimitSettings.PolicyDefinition policy( + RateLimitAlgorithm algorithm, + Long limit, + Duration window, + Long capacity, + Long refillTokens, + Duration refillPeriod) { + return new RedisRateLimitSettings.PolicyDefinition( + "r1", + algorithm, + limit, + window, + capacity, + refillTokens, + refillPeriod, + 10L, + Duration.ofSeconds(5), + Duration.ofMillis(250), + null, + null, + null, + null); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramCatalogTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramCatalogTest.java new file mode 100644 index 0000000..a63bc09 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRateProgramCatalogTest.java @@ -0,0 +1,186 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisRateProgramCatalogTest { + + private static final Set V1_RATE_STATUSES = + Set.of("ALLOWED", "DENIED", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"); + private static final Set V2_RATE_STATUSES = + Set.of("ALLOWED", "DENIED", "DEDUP_REPLAY", "CLOCK_UNSAFE", "STATE_INCOMPATIBLE", "INVALID"); + + @Test + void ownsParallelV1CompatibilityAndV2DeduplicatingPrograms() { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + + assertThat(catalog.descriptors()) + .extracting(RedisProgramDescriptor::id) + .containsExactlyInAnyOrder( + RedisProgramId.RATE_FIXED_WINDOW, + RedisProgramId.RATE_SLIDING_COUNTER, + RedisProgramId.RATE_TOKEN_BUCKET, + RedisProgramId.RATE_FIXED_WINDOW_V2, + RedisProgramId.RATE_SLIDING_COUNTER_V2, + RedisProgramId.RATE_TOKEN_BUCKET_V2); + assertThat(catalog.descriptor(RedisProgramId.RATE_FIXED_WINDOW).argumentCount()).isEqualTo(7); + assertThat(catalog.descriptor(RedisProgramId.RATE_SLIDING_COUNTER).argumentCount()) + .isEqualTo(7); + assertThat(catalog.descriptor(RedisProgramId.RATE_TOKEN_BUCKET).argumentCount()).isEqualTo(8); + assertThat(catalog.descriptor(RedisProgramId.RATE_FIXED_WINDOW_V2).argumentCount()) + .isEqualTo(11); + assertThat(catalog.descriptor(RedisProgramId.RATE_SLIDING_COUNTER_V2).argumentCount()) + .isEqualTo(11); + assertThat(catalog.descriptor(RedisProgramId.RATE_TOKEN_BUCKET_V2).argumentCount()) + .isEqualTo(12); + } + + @Test + void v1DescriptorsRemainByteCompatibleWhileV2PinsBoundedDedupShape() { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + + List.of( + RedisProgramId.RATE_FIXED_WINDOW, + RedisProgramId.RATE_SLIDING_COUNTER, + RedisProgramId.RATE_TOKEN_BUCKET) + .forEach( + id -> { + RedisProgramDescriptor descriptor = catalog.descriptor(id); + assertThat(descriptor.keyCount()).isEqualTo(1); + assertThat(descriptor.replyFieldCount()).isEqualTo(7); + assertThat(descriptor.statuses()).isEqualTo(V1_RATE_STATUSES); + assertThat(id.externalId()).endsWith("-v1"); + }); + + v2Descriptors(catalog) + .forEach( + descriptor -> { + assertThat(descriptor.keyCount()).isEqualTo(3); + assertThat(descriptor.replyFieldCount()).isEqualTo(8); + assertThat(descriptor.maximumReplyFieldBytes()).isBetween(1, 64); + assertThat(descriptor.statuses()).isEqualTo(V2_RATE_STATUSES); + assertThat(descriptor.id().externalId()).endsWith("-v2"); + assertThat(descriptor.sha256()).matches("[0-9a-f]{64}"); + assertThat( + descriptor.scriptBytes().length + + descriptor.maximumKeyBytes() + + (descriptor.argumentCount() * descriptor.maximumArgumentBytes()) + + 1024) + .isLessThanOrEqualTo(16_384); + assertThat(new String(descriptor.scriptBytes(), UTF_8)) + .contains("redis.call('TIME')") + .contains("if type(value) ~= 'string' or value == '' then") + .contains("local function key_type(key)") + .contains("local state_type = key_type(KEYS[1])") + .contains("redis.call('HGET', KEYS[2], evaluation_id)") + .contains("redis.call('ZPOPMIN', KEYS[3], 1)") + .contains("MAXIMUM_DEDUP_DATA_BYTES") + .doesNotContain("KEYS[4]"); + }); + } + + @Test + void everyProgramValidatesDedupBoundsBeforeItsFirstMutation() { + v2Descriptors(RedisProgramCatalog.rateLimit()) + .forEach( + descriptor -> { + String source = new String(descriptor.scriptBytes(), UTF_8); + + assertThat(source.indexOf("local evaluation_id = ARGV[")) + .isGreaterThan(0) + .isLessThan(source.indexOf("redis.call(\n 'HSET'")); + assertThat(source.indexOf("valid_evaluation_id(evaluation_id)")) + .isGreaterThan(0) + .isLessThan(source.indexOf("redis.call(\n 'HSET'")); + assertThat(source) + .contains("MAXIMUM_EVALUATION_ID_BYTES = 71") + .contains("MAXIMUM_DEDUP_ENTRIES = 1024") + .contains("MAXIMUM_DEDUP_TTL_MS = 300000") + .contains("MAXIMUM_DEDUP_DATA_BYTES = 262144"); + }); + } + + @Test + void machineReadableManifestMatchesTheCompiledRateCatalog() throws IOException { + String manifest; + try (InputStream input = + RedisRateProgramCatalogTest.class + .getClassLoader() + .getResourceAsStream("redis/rate-program-set.json")) { + assertThat(input).isNotNull(); + manifest = new String(input.readAllBytes(), UTF_8); + } + + assertThat(JsonPath.read(manifest, "$.minimumRedisVersion")).isEqualTo("7.2"); + assertThat(JsonPath.read(manifest, "$.readiness")).isEqualTo("R1"); + List> programs = JsonPath.read(manifest, "$.programs"); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + assertThat(programs).hasSameSizeAs(catalog.descriptors()); + programs.forEach( + program -> { + RedisProgramDescriptor descriptor = + catalog.descriptors().stream() + .filter(candidate -> candidate.id().externalId().equals(program.get("id"))) + .findFirst() + .orElseThrow(); + assertThat(program.get("sha256")).isEqualTo(descriptor.sha256()); + assertThat(program.get("keyCount")).isEqualTo(descriptor.keyCount()); + assertThat(program.get("argumentCount")).isEqualTo(descriptor.argumentCount()); + assertThat(program.get("replyFieldCount")).isEqualTo(descriptor.replyFieldCount()); + assertThat(Set.copyOf((List) program.get("statuses"))) + .isEqualTo(descriptor.statuses()); + }); + } + + @Test + void tokenBucketPreservesSubTokenRefillTimeAndDenyDoesNotSubtractQuota() { + String source = + new String( + RedisProgramCatalog.rateLimit() + .descriptor(RedisProgramId.RATE_TOKEN_BUCKET_V2) + .scriptBytes(), + UTF_8); + + assertThat(source) + .contains("local refill_remainder = 0") + .contains("local combined_remainder = partial_remainder + refill_remainder") + .contains("if allowed then\n new_tokens = available - cost_scaled\nend") + .contains("'lastRefillMillis', number(effective_now)") + .contains("'refillRemainder', number(new_refill_remainder)") + .doesNotContain("numerator + denominator - 1"); + } + + @Test + void slidingCounterInvertsTheConservativeWeightForTheEarliestBoundedRetry() { + String source = + new String( + RedisProgramCatalog.rateLimit() + .descriptor(RedisProgramId.RATE_SLIDING_COUNTER_V2) + .scriptBytes(), + UTF_8); + + assertThat(source) + .contains("local function ceiling_divide(numerator, denominator)") + .contains("ceiling_divide(remaining_window * SCALE, window_ms)") + .contains("local maximum_previous_weight") + .contains("local maximum_remaining") + .contains("remaining_window - maximum_remaining") + .contains("remaining_window + elapsed_after_rollover") + .doesNotContain("remaining_window * SCALE + window_ms - 1"); + } + + private static List v2Descriptors(RedisProgramCatalog catalog) { + return List.of( + catalog.descriptor(RedisProgramId.RATE_FIXED_WINDOW_V2), + catalog.descriptor(RedisProgramId.RATE_SLIDING_COUNTER_V2), + catalog.descriptor(RedisProgramId.RATE_TOKEN_BUCKET_V2)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouterTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouterTest.java new file mode 100644 index 0000000..9689d40 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRoleCommandRouterTest.java @@ -0,0 +1,1228 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RedisRoleCommandRouterTest { + + @Test + void observesBoundedPressureForAdmissionSaturationReleaseAndClosedBarrier() throws Exception { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + runtime.blockReads = true; + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 1, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + RedisDrainWaiter.system()); + CompletableFuture> first = + CompletableFuture.supplyAsync(() -> router.read("key")); + assertThat(runtime.entered.await(2, TimeUnit.SECONDS)).isTrue(); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("second"))) + .isInstanceOf(RedisCommandFailureException.class); + runtime.release.countDown(); + first.join(); + router.close(); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("closed"))) + .isInstanceOf(IllegalStateException.class); + + assertThat(observations.events()) + .filteredOn(RedisCapabilityObservationEvent.AdmissionChanged.class::isInstance) + .map(RedisCapabilityObservationEvent.AdmissionChanged.class::cast) + .extracting( + RedisCapabilityObservationEvent.AdmissionChanged::admission, + RedisCapabilityObservationEvent.AdmissionChanged::state) + .contains( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.AdmissionState.ADMITTED, + RedisCapabilityObservationEvent.InFlightState.SATURATED), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED, + RedisCapabilityObservationEvent.InFlightState.SATURATED), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.AdmissionState.NOT_APPLICABLE, + RedisCapabilityObservationEvent.InFlightState.IDLE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.AdmissionState.REJECTED_CLOSED, + RedisCapabilityObservationEvent.InFlightState.IDLE)); + assertThat(observations.operations()) + .extracting( + RedisCapabilityObservationEvent.OperationCompleted::operation, + RedisCapabilityObservationEvent.OperationCompleted::outcome, + RedisCapabilityObservationEvent.OperationCompleted::certainty) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND, + RedisCapabilityObservationEvent.Outcome.OVERLOADED, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND, + RedisCapabilityObservationEvent.Outcome.SUCCESS, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND, + RedisCapabilityObservationEvent.Outcome.CLOSED, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED)); + assertThat(observations.operations()) + .allSatisfy( + event -> + assertThat(event.durationNanos()) + .isBetween(0L, RedisCapabilityObservationEvent.MAXIMUM_DURATION_NANOS)); + } + + @Test + void probesSwapsRoutesNewCommandsThenDrainsAndClosesOldRuntime() throws Exception { + FakeRuntime oldRuntime = new FakeRuntime("cache-v1"); + FakeRuntime newRuntime = new FakeRuntime("cache-v2"); + oldRuntime.blockReads = true; + RedisRoleCommandRouter router = router(oldRuntime, 4); + CompletableFuture> oldRead = + CompletableFuture.supplyAsync(() -> router.read("key")); + assertThat(oldRuntime.entered.await(2, TimeUnit.SECONDS)).isTrue(); + + CompletableFuture rotation = + CompletableFuture.supplyAsync( + () -> router.swap(newRuntime, Duration.ofMillis(100), Duration.ofSeconds(2))); + assertThat(newRuntime.probed.await(2, TimeUnit.SECONDS)).isTrue(); + + assertThat(awaitRoutedValue(router, "key", "cache-v2")).contains("cache-v2"); + assertThat(oldRuntime.closed).isFalse(); + oldRuntime.release.countDown(); + + assertThat(oldRead.join()).contains("cache-v1"); + assertThat(rotation.join()).isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + assertThat(oldRuntime.closed).isTrue(); + assertThat(newRuntime.closed).isFalse(); + router.close(); + } + + @Test + void failedCandidateProbePreservesOldRouteAndClosesCandidate() { + FakeRuntime oldRuntime = new FakeRuntime("cache-v1"); + FakeRuntime rejected = new FakeRuntime("cache-v2"); + rejected.failProbe = true; + try (RedisRoleCommandRouter router = router(oldRuntime, 2)) { + + RedisRoleCommandRouter.SwapResult result = + router.swap(rejected, Duration.ofMillis(50), Duration.ofMillis(100)); + + assertThat(result).isEqualTo(RedisRoleCommandRouter.SwapResult.PROBE_FAILED); + assertThat(router.read("key")).contains("cache-v1"); + assertThat(oldRuntime.closed).isFalse(); + assertThat(rejected.closed).isTrue(); + } + } + + @Test + void routeGenerationChangesOnlyWhenACandidateIsInstalled() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime rejected = new FakeRuntime("cache-rejected"); + rejected.failProbe = true; + FakeRuntime installed = new FakeRuntime("cache-v2"); + try (RedisRoleCommandRouter router = router(initial, 2)) { + RedisRoleCommandRouter.RouteToken initialToken = router.routeToken(); + + assertThat(initialToken.generation()).isZero(); + assertThat(router.swap(rejected, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.PROBE_FAILED); + assertThat(router.routeToken()).isEqualTo(initialToken); + + assertThat(router.swap(installed, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + assertThat(router.routeToken().generation()).isEqualTo(1); + assertThat(router.routeToken().identity()).isEqualTo(installed.routeIdentity()); + } + } + + @Test + void staleConditionalCandidateClosesOnceWithoutChangingTheActiveRoute() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime installed = new FakeRuntime("cache-v2"); + FakeRuntime stale = new FakeRuntime("cache-stale"); + try (RedisRoleCommandRouter router = router(initial, 2)) { + RedisRoleCommandRouter.RouteToken oldToken = router.routeToken(); + assertThat(router.swap(installed, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + RedisRoleCommandRouter.RouteToken installedToken = router.routeToken(); + + assertThat( + router.swapIfGeneration( + oldToken, stale, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.STALE_GENERATION); + + assertThat(stale.closeCalls).hasValue(1); + assertThat(stale.probes).hasValue(0); + assertThat(installed.closed).isFalse(); + assertThat(router.routeToken()).isEqualTo(installedToken); + assertThat(router.read("key")).contains("cache-v2"); + } + } + + @Test + void sameGenerationTokenFromAnotherRouterIsStaleAndCannotInstallItsCandidate() { + FakeRuntime firstInitial = new FakeRuntime("cache-first"); + FakeRuntime otherInitial = new FakeRuntime("cache-other"); + FakeRuntime rejected = new FakeRuntime("cache-rejected"); + try (RedisRoleCommandRouter first = router(firstInitial, 2); + RedisRoleCommandRouter other = router(otherInitial, 2)) { + RedisRoleCommandRouter.RouteToken foreignToken = other.routeToken(); + + assertThat(foreignToken.generation()).isEqualTo(first.routeToken().generation()); + assertThat( + first.swapIfGeneration( + foreignToken, rejected, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.STALE_GENERATION); + + assertThat(rejected.closeCalls).hasValue(1); + assertThat(rejected.probes).hasValue(0); + assertThat(firstInitial.closed).isFalse(); + assertThat(first.read("key")).contains("cache-first"); + } + } + + @Test + void invalidCandidateIdentityClosesCandidateWithoutProbeOrActiveRouteMutation() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime nullIdentity = new FakeRuntime("cache-null-identity"); + nullIdentity.nullIdentity = true; + FakeRuntime throwingIdentity = new FakeRuntime("cache-throwing-identity"); + throwingIdentity.throwIdentity = true; + try (RedisRoleCommandRouter router = router(initial, 2)) { + RedisRoleCommandRouter.RouteToken token = router.routeToken(); + + assertThat( + router.swapIfGeneration( + token, nullIdentity, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.PROBE_FAILED); + assertThat( + router.swapIfGeneration( + token, throwingIdentity, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.PROBE_FAILED); + + assertThat(nullIdentity.closeCalls).hasValue(1); + assertThat(throwingIdentity.closeCalls).hasValue(1); + assertThat(nullIdentity.probes).hasValue(0); + assertThat(throwingIdentity.probes).hasValue(0); + assertThat(initial.closed).isFalse(); + assertThat(router.routeToken()).isEqualTo(token); + } + } + + @Test + void installedCandidateIdentityIsCapturedExactlyOnce() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime unstableIdentity = new FakeRuntime("cache-unstable-identity"); + unstableIdentity.unstableIdentity = true; + try (RedisRoleCommandRouter router = router(initial, 2)) { + RedisRoleCommandRouter.RouteToken token = router.routeToken(); + + assertThat( + router.swapIfGeneration( + token, unstableIdentity, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + + assertThat(unstableIdentity.identityCalls).hasValue(1); + assertThat(router.routeToken().identity()).isNotNull(); + } + } + + @Test + void samePrimaryCandidateClosesOnceWithoutProbeInstallOrDrain() { + RedisRouteIdentity primary = + RedisRouteIdentity.sentinel( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + FakeRuntime initial = new FakeRuntime("cache-v1", primary); + FakeRuntime duplicate = new FakeRuntime("cache-v2", primary); + try (RedisRoleCommandRouter router = router(initial, 2)) { + RedisRoleCommandRouter.RouteToken token = router.routeToken(); + + assertThat( + router.swapIfGeneration( + token, duplicate, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.SAME_ROUTE); + + assertThat(duplicate.closeCalls).hasValue(1); + assertThat(duplicate.probes).hasValue(0); + assertThat(initial.closed).isFalse(); + assertThat(router.routeToken()).isEqualTo(token); + } + } + + @Test + void activeRuntimeOwnershipUsesObjectIdentityRatherThanRouteIdentity() { + RedisRouteIdentity primary = + RedisRouteIdentity.sentinel( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + FakeRuntime initial = new FakeRuntime("cache-v1", primary); + FakeRuntime sameIdentity = new FakeRuntime("cache-v2", primary); + FakeRuntime installed = new FakeRuntime("cache-v3"); + try (RedisRoleCommandRouter router = router(initial, 2)) { + assertThat(router.ownsRuntime(initial)).isTrue(); + assertThat(router.ownsRuntime(sameIdentity)).isFalse(); + + router.swap(installed, Duration.ofMillis(50), Duration.ofMillis(100)); + + assertThat(router.ownsRuntime(initial)).isFalse(); + assertThat(router.ownsRuntime(installed)).isTrue(); + } + } + + @Test + void unconditionalRotationMakesAnOlderConditionalCandidateStale() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime manuallyRotated = new FakeRuntime("cache-manual"); + FakeRuntime olderSentinelCandidate = new FakeRuntime("cache-sentinel-old"); + try (RedisRoleCommandRouter router = router(initial, 2)) { + RedisRoleCommandRouter.RouteToken discoveryToken = router.routeToken(); + + assertThat(router.swap(manuallyRotated, Duration.ofMillis(50), Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + assertThat( + router.swapIfGeneration( + discoveryToken, + olderSentinelCandidate, + Duration.ofMillis(50), + Duration.ofMillis(100))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.STALE_GENERATION); + + assertThat(olderSentinelCandidate.closeCalls).hasValue(1); + assertThat(manuallyRotated.closed).isFalse(); + assertThat(router.routeToken().generation()).isEqualTo(1); + assertThat(router.read("key")).contains("cache-manual"); + } + } + + @Test + void hintedMutationSignalsAfterLeaseReleaseWithoutRetryingAndPreservesFailure() { + FakeRuntime oldRuntime = new FakeRuntime("coord-v1"); + RedisCommandFailureException original = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL, + "write unavailable", + null); + oldRuntime.writeFailure = original; + FakeRuntime candidate = new FakeRuntime("coord-v2"); + AtomicReference routerReference = new AtomicReference<>(); + AtomicReference signalSwap = new AtomicReference<>(); + AtomicReference signaledFailure = new AtomicReference<>(); + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + oldRuntime, + 16_384, + new RecordingRedisCapabilityObservations(), + (token, failure) -> { + signaledFailure.set(failure); + signalSwap.set( + routerReference + .get() + .swapIfGeneration( + token, candidate, Duration.ofMillis(50), Duration.ofMillis(100))); + }); + routerReference.set(router); + + Throwable thrown = + org.assertj.core.api.Assertions.catchThrowable( + () -> + router.set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1}), + RedisBinaryValue.utf8("value"), + Duration.ofSeconds(5))); + + assertThat(thrown).isSameAs(original); + assertThat(signaledFailure).hasValue(original); + assertThat(signalSwap).hasValue(RedisRoleCommandRouter.SwapResult.DRAINED); + assertThat(oldRuntime.writes).hasValue(1); + assertThat(candidate.writes).hasValue(0); + assertThat(oldRuntime.closeCalls).hasValue(1); + router.close(); + } + + @Test + void noneOverloadAndAclFailuresDoNotSignalTopologyRecovery() { + AtomicInteger signals = new AtomicInteger(); + FakeRuntime runtime = new FakeRuntime("coord-v1"); + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 1024, + new RecordingRedisCapabilityObservations(), + (token, failure) -> signals.incrementAndGet()); + + runtime.readFailure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "ordinary unavailable", + null); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("key"))) + .isSameAs(runtime.readFailure); + + runtime.readFailure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.ACL_DENIED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "acl denied", + null); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("key"))) + .isSameAs(runtime.readFailure); + + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> + router.set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[800]), + RedisBinaryValue.encoded(new byte[300]), + Duration.ofSeconds(5)))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("kind") + .isEqualTo(RedisCommandFailureException.Kind.OVERLOADED); + assertThat(signals).hasValue(0); + router.close(); + } + + @Test + void topologyListenerFailureCannotReplaceOriginalFailureOrCertainty() { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + RedisCommandFailureException original = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL, + "write unavailable", + null); + runtime.writeFailure = original; + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 16_384, + new RecordingRedisCapabilityObservations(), + (token, failure) -> { + throw new IllegalStateException("listener detail must not escape"); + }); + + Throwable thrown = + org.assertj.core.api.Assertions.catchThrowable( + () -> + router.set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1}), + RedisBinaryValue.utf8("value"), + Duration.ofSeconds(5))); + + assertThat(thrown).isSameAs(original); + assertThat(((RedisCommandFailureException) thrown).certainty()) + .isEqualTo(RedisCommandFailureException.Certainty.INDETERMINATE); + assertThat(runtime.writes).hasValue(1); + router.close(); + } + + @Test + void boundedDrainForcesOldCloseAndInFlightAdmissionIsBounded() throws Exception { + FakeRuntime oldRuntime = new FakeRuntime("coord-v1"); + oldRuntime.blockReads = true; + RedisRoleCommandRouter router = router(oldRuntime, 1); + CompletableFuture> first = + CompletableFuture.supplyAsync(() -> router.read("key")); + assertThat(oldRuntime.entered.await(2, TimeUnit.SECONDS)).isTrue(); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("second"))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("kind") + .isEqualTo(RedisCommandFailureException.Kind.OVERLOADED); + + FakeRuntime replacement = new FakeRuntime("coord-v2"); + assertThat(router.swap(replacement, Duration.ofMillis(50), Duration.ofMillis(10))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.FORCED_AFTER_TIMEOUT); + assertThat(oldRuntime.closed).isTrue(); + oldRuntime.release.countDown(); + first.join(); + router.close(); + } + + @Test + void rejectsOversizeCommandsAndByteBudgetSaturationBeforeSending() throws Exception { + FakeRuntime runtime = new FakeRuntime("cache-v1"); + runtime.blockReads = true; + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.CACHE, + runtime, + 4, + 1024, + 1024, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + RedisDrainWaiter.system()); + + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> + router.set( + RedisPhysicalKeyTestFactory.fromEncoded(new byte[800]), + RedisBinaryValue.encoded(new byte[300]), + Duration.ofMinutes(1)))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("certainty") + .isEqualTo(RedisCommandFailureException.Certainty.NOT_APPLIED); + assertThat(runtime.writes).hasValue(0); + assertThat(observations.events()) + .filteredOn(RedisCapabilityObservationEvent.AdmissionChanged.class::isInstance) + .map(RedisCapabilityObservationEvent.AdmissionChanged.class::cast) + .extracting(RedisCapabilityObservationEvent.AdmissionChanged::admission) + .contains(RedisCapabilityObservationEvent.AdmissionState.REJECTED_SATURATED); + assertThat(observations.operations()) + .singleElement() + .satisfies( + event -> { + assertThat(event.operation()) + .isEqualTo(RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND); + assertThat(event.outcome()) + .isEqualTo(RedisCapabilityObservationEvent.Outcome.OVERLOADED); + assertThat(event.certainty()) + .isEqualTo(RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + }); + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisProgramDescriptor descriptor = catalog.descriptor(RedisProgramId.BOUNDED_GET_V1); + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> + RedisProgramTestInvocations.scalar( + catalog, + descriptor.id(), + java.util.Collections.nCopies(257, new byte[] {1}), + List.of(new byte[] {1})))) + .isInstanceOf(IllegalArgumentException.class); + + CompletableFuture> first = + CompletableFuture.supplyAsync(() -> router.read("key")); + assertThat(runtime.entered.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> router.delete(RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1})))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("kind") + .isEqualTo(RedisCommandFailureException.Kind.OVERLOADED); + runtime.release.countDown(); + first.join(); + router.close(); + } + + @Test + void observesDefiniteAndIndeterminateRouteFailuresWithoutExceptionDetail() { + assertRouteFailure( + RedisCommandFailureException.Certainty.NOT_APPLIED, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + assertRouteFailure( + RedisCommandFailureException.Certainty.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + } + + @Test + void diagnosticTickerFailureCannotReplaceRouteFailureOrHealthQuery() { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + runtime.failReads = true; + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + () -> { + throw new IllegalStateException("diagnostic ticker failed"); + }, + NoOpRedisCapabilityObservationPort.instance(), + RedisDrainWaiter.system()); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("key"))) + .isInstanceOf(RedisCommandFailureException.class); + assertThat(org.assertj.core.api.Assertions.catchThrowable(router::hadRecentCommandFailure)) + .isNull(); + assertThat(router.hadRecentCommandFailure()).isFalse(); + assertThat(runtime.reads).hasValue(1); + router.close(); + } + + @Test + void diagnosticTickerFailurePreservesAnEarlierValidRecentFailureSignal() { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + runtime.failReads = true; + AtomicLong ticker = new AtomicLong(100L); + java.util.concurrent.atomic.AtomicBoolean failTicker = + new java.util.concurrent.atomic.AtomicBoolean(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + () -> { + if (failTicker.get()) { + throw new IllegalStateException("diagnostic ticker failed"); + } + return ticker.get(); + }, + NoOpRedisCapabilityObservationPort.instance(), + RedisDrainWaiter.system()); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("first"))) + .isInstanceOf(RedisCommandFailureException.class); + assertThat(router.hadRecentCommandFailure()).isTrue(); + + failTicker.set(true); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("second"))) + .isInstanceOf(RedisCommandFailureException.class); + failTicker.set(false); + + assertThat(router.hadRecentCommandFailure()).isTrue(); + assertThat(runtime.reads).hasValue(2); + router.close(); + } + + @Test + void noScriptLoadAndRetryRemainOneLogicalRouteOperation() { + RecoveryRuntime runtime = new RecoveryRuntime(); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + RedisDrainWaiter.system()); + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisCatalogProgramInvocation invocation = + RedisProgramTestInvocations.scalar( + catalog, + RedisProgramId.BOUNDED_GET_V1, + List.of("key".getBytes(StandardCharsets.US_ASCII)), + List.of("1024".getBytes(StandardCharsets.US_ASCII))); + + assertThat(RedisScriptRecovery.evalValue(router, invocation)) + .containsExactly("recovered".getBytes(StandardCharsets.US_ASCII)); + + assertThat(runtime.trace).containsExactly("EVALSHA", "SCRIPT_LOAD", "EVALSHA"); + assertThat(observations.operations()) + .singleElement() + .satisfies( + event -> { + assertThat(event.operation()) + .isEqualTo(RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND); + assertThat(event.outcome()) + .isEqualTo(RedisCapabilityObservationEvent.Outcome.SUCCESS); + assertThat(event.certainty()) + .isEqualTo(RedisCapabilityObservationEvent.Certainty.DEFINITE); + }); + router.close(); + } + + @Test + void oversizedReadReplyIsOneDefiniteUnavailableRouteOperation() { + FakeRuntime runtime = new FakeRuntime("cache-v1"); + runtime.readReply = new byte[1025]; + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.CACHE, + runtime, + 1024, + observations); + + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> router.get(RedisPhysicalKeyTestFactory.fromEncoded(new byte[] {1})))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("certainty") + .isEqualTo(RedisCommandFailureException.Certainty.NOT_APPLIED); + assertThat(runtime.reads).hasValue(1); + assertSingleRouteFailure(observations, RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + router.close(); + } + + @Test + void oversizedMutationValueReplyIsOneIndeterminateUnavailableRouteOperation() { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + runtime.catalogReply = RedisCatalogProgramReply.value(new byte[1025]); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 1024, + observations); + RedisProgramCatalog catalog = RedisProgramCatalog.foundation(); + RedisCatalogProgramInvocation invocation = + RedisProgramTestInvocations.scalar( + catalog, + RedisProgramId.COMPARE_AND_DELETE, + List.of(new byte[] {1}), + List.of(new byte[] {1})); + + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> router.executeCatalogProgram(invocation))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("certainty") + .isEqualTo(RedisCommandFailureException.Certainty.INDETERMINATE); + assertSingleRouteFailure(observations, RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + router.close(); + } + + @Test + void oversizedMutationMultiReplyIsOneIndeterminateUnavailableRouteOperation() { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + runtime.catalogReply = RedisCatalogProgramReply.multi(List.of(new byte[1025])); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 1024, + observations); + RedisPrimitiveCatalog primitiveCatalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveDescriptor primitiveDescriptor = + primitiveCatalog.descriptor(RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL); + RedisPrimitiveInvocation primitive = + new RedisPrimitiveInvocation( + primitiveCatalog, + primitiveDescriptor, + List.of( + primitiveCatalog + .keyFactory(RedisPrimitiveId.COUNTER_INCREMENT_INITIAL_TTL) + .key("tenant", "counter")), + new RedisPrimitiveInvocation.CounterArguments(1, 0, 10, Duration.ofSeconds(30)), + System::nanoTime); + RedisProgramCatalog programCatalog = RedisProgramCatalog.primitiveAtomic(); + RedisCatalogProgramInvocation invocation = + programCatalog.primitiveMultiInvocation( + programCatalog.descriptor(primitiveDescriptor.programId()), primitive, false); + + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> router.executeCatalogProgram(invocation))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("certainty") + .isEqualTo(RedisCommandFailureException.Certainty.INDETERMINATE); + assertSingleRouteFailure(observations, RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + router.close(); + } + + @Test + void expiredPrimitiveDeadlineIsOneDefiniteUnavailableRouteOperationWithoutRuntimeDispatch() { + FakeRuntime runtime = new FakeRuntime("cache-v1"); + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.CACHE, + runtime, + 65_536, + observations); + RedisPrimitiveCatalog catalog = RedisPrimitiveCatalog.standard(); + RedisPrimitiveDescriptor descriptor = catalog.descriptor(RedisPrimitiveId.STRING_GET); + AtomicLong ticker = new AtomicLong(); + RedisPrimitiveInvocation invocation = + new RedisPrimitiveInvocation( + catalog, + descriptor, + List.of(catalog.keyFactory(RedisPrimitiveId.STRING_GET).key("tenant", "key")), + RedisPrimitiveInvocation.NoArguments.INSTANCE, + ticker::get); + ticker.set(descriptor.totalDeadline().toNanos()); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.execute(invocation))) + .isInstanceOf(RedisCommandFailureException.class) + .extracting("certainty") + .isEqualTo(RedisCommandFailureException.Certainty.NOT_APPLIED); + assertThat(runtime.primitiveCalls).hasValue(0); + assertSingleRouteFailure(observations, RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + router.close(); + } + + @Test + void preparesCandidateSubscriptionBeforeSwapAndSuppressesPlannedOldDisconnect() { + FakeRuntime oldRuntime = new FakeRuntime("cache-v1"); + FakeRuntime newRuntime = new FakeRuntime("cache-v2"); + RedisRoleCommandRouter router = router(oldRuntime, 4); + AtomicReference lastMessage = new AtomicReference<>(); + AtomicInteger disconnects = new AtomicInteger(); + RedisInvalidationTransport.Subscription subscription = + router.subscribe( + "cache-invalidation".getBytes(StandardCharsets.US_ASCII), + new RedisInvalidationTransport.Listener() { + @Override + public void onMessage(byte[] wireMessage) { + lastMessage.set(new String(wireMessage, StandardCharsets.US_ASCII)); + } + + @Override + public void onDisconnected() { + disconnects.incrementAndGet(); + } + }); + + oldRuntime.emit("old"); + assertThat(lastMessage).hasValue("old"); + assertThat(router.swap(newRuntime, Duration.ofMillis(50), Duration.ofSeconds(1))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.DRAINED); + oldRuntime.disconnect(); + assertThat(disconnects).hasValue(0); + newRuntime.emit("new"); + assertThat(lastMessage).hasValue("new"); + newRuntime.disconnect(); + assertThat(disconnects).hasValue(1); + assertThat( + router.publish( + "cache-invalidation".getBytes(StandardCharsets.US_ASCII), + "{}".getBytes(StandardCharsets.US_ASCII))) + .isEqualTo(1); + assertThat(newRuntime.publishes).hasValue(1); + + subscription.close(); + router.close(); + } + + @Test + void candidateSubscriptionFailurePreservesOldRouteAndOldSubscription() { + FakeRuntime oldRuntime = new FakeRuntime("cache-v1"); + FakeRuntime rejected = new FakeRuntime("cache-v2"); + rejected.failSubscription = true; + RedisRoleCommandRouter router = router(oldRuntime, 4); + AtomicReference lastMessage = new AtomicReference<>(); + RedisInvalidationTransport.Subscription subscription = + router.subscribe( + "cache-invalidation".getBytes(StandardCharsets.US_ASCII), + new RedisInvalidationTransport.Listener() { + @Override + public void onMessage(byte[] wireMessage) { + lastMessage.set(new String(wireMessage, StandardCharsets.US_ASCII)); + } + + @Override + public void onDisconnected() {} + }); + + assertThat(router.swap(rejected, Duration.ofMillis(50), Duration.ofSeconds(1))) + .isEqualTo(RedisRoleCommandRouter.SwapResult.PROBE_FAILED); + assertThat(rejected.closed).isTrue(); + assertThat(oldRuntime.closed).isFalse(); + oldRuntime.emit("still-old"); + assertThat(lastMessage).hasValue("still-old"); + + subscription.close(); + router.close(); + } + + @Test + void recentFailureWindowUsesInjectedMonotonicTickerAcrossConcurrentFailuresAndExpiry() + throws Exception { + AtomicLong ticker = new AtomicLong(Long.MAX_VALUE - Duration.ofSeconds(2).toNanos()); + FakeRuntime runtime = new FakeRuntime("cache-v1"); + runtime.failReads = true; + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + runtime, + 64, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + ticker::get); + + try (router; + var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var failures = + java.util.stream.IntStream.range(0, 32) + .mapToObj( + ignored -> + executor.submit( + () -> + org.assertj.core.api.Assertions.catchThrowable( + () -> router.read("key")))) + .toList(); + for (var failure : failures) { + assertThat(failure.get(1, TimeUnit.SECONDS)) + .isInstanceOf(RedisCommandFailureException.class); + } + assertThat(router.hadRecentCommandFailure()).isTrue(); + + ticker.addAndGet(Duration.ofSeconds(30).toNanos()); + assertThat(router.hadRecentCommandFailure()).isTrue(); + ticker.incrementAndGet(); + assertThat(router.hadRecentCommandFailure()).isFalse(); + + ticker.set(1L); + assertThat(router.hadRecentCommandFailure()).isFalse(); + } + } + + private static RedisRoleCommandRouter router( + RedisRoutableCommandRuntime runtime, int maximumInFlight) { + return new RedisRoleCommandRouter( + runtime, maximumInFlight, 16_384, 1_048_576, Duration.ofSeconds(2), Duration.ofMinutes(5)); + } + + private static Optional awaitRoutedValue( + RedisRoleCommandRouter router, String key, String expected) { + long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos(); + Optional actual; + do { + actual = router.read(key); + if (actual.filter(expected::equals).isPresent()) { + return actual; + } + java.util.concurrent.locks.LockSupport.parkNanos(Duration.ofMillis(1).toNanos()); + } while (System.nanoTime() < deadline); + return actual; + } + + private static RedisRoleCommandRouter observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole role, + RedisRoutableCommandRuntime runtime, + int maximumCommandBytes, + RecordingRedisCapabilityObservations observations) { + return new RedisRoleCommandRouter( + role, + runtime, + 4, + maximumCommandBytes, + Math.max(maximumCommandBytes, 1_048_576), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + RedisDrainWaiter.system()); + } + + private static RedisRoleCommandRouter observedRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole role, + RedisRoutableCommandRuntime runtime, + int maximumCommandBytes, + RecordingRedisCapabilityObservations observations, + RedisRoleCommandRouter.TopologyFailureListener topologyFailureListener) { + return new RedisRoleCommandRouter( + role, + runtime, + 4, + maximumCommandBytes, + Math.max(maximumCommandBytes, 1_048_576), + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + RedisDrainWaiter.system(), + topologyFailureListener); + } + + private static void assertSingleRouteFailure( + RecordingRedisCapabilityObservations observations, + RedisCapabilityObservationEvent.Certainty certainty) { + assertThat(observations.operations()) + .singleElement() + .satisfies( + event -> { + assertThat(event.operation()) + .isEqualTo(RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND); + assertThat(event.outcome()) + .isEqualTo(RedisCapabilityObservationEvent.Outcome.UNAVAILABLE); + assertThat(event.certainty()).isEqualTo(certainty); + }); + } + + private static void assertRouteFailure( + RedisCommandFailureException.Certainty commandCertainty, + RedisCapabilityObservationEvent.Certainty expectedCertainty) { + FakeRuntime runtime = new FakeRuntime("coord-v1"); + runtime.failReads = true; + runtime.failureCertainty = commandCertainty; + RecordingRedisCapabilityObservations observations = new RecordingRedisCapabilityObservations(); + RedisRoleCommandRouter router = + new RedisRoleCommandRouter( + dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole.COORDINATION, + runtime, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(2), + Duration.ofMinutes(5), + System::nanoTime, + observations, + RedisDrainWaiter.system()); + + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> router.read("identity"))) + .isInstanceOf(RedisCommandFailureException.class); + assertThat(observations.operations()) + .singleElement() + .satisfies( + event -> { + assertThat(event.operation()) + .isEqualTo(RedisCapabilityObservationEvent.Operation.ROUTE_COMMAND); + assertThat(event.outcome()) + .isEqualTo(RedisCapabilityObservationEvent.Outcome.UNAVAILABLE); + assertThat(event.certainty()).isEqualTo(expectedCertainty); + assertThat(event.toString()).doesNotContain("identity", "read unavailable"); + }); + router.close(); + } + + private static final class FakeRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final RedisRouteIdentity routeIdentity; + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final CountDownLatch probed = new CountDownLatch(1); + private final AtomicInteger writes = new AtomicInteger(); + private final AtomicInteger reads = new AtomicInteger(); + private final AtomicInteger publishes = new AtomicInteger(); + private final AtomicInteger primitiveCalls = new AtomicInteger(); + private final AtomicInteger probes = new AtomicInteger(); + private final AtomicInteger closeCalls = new AtomicInteger(); + private final AtomicInteger identityCalls = new AtomicInteger(); + private volatile boolean blockReads; + private volatile boolean failProbe; + private volatile boolean closed; + private volatile RedisInvalidationTransport.Listener invalidationListener; + private volatile boolean invalidationSubscriptionClosed; + private volatile boolean failSubscription; + private volatile boolean failReads; + private volatile boolean nullIdentity; + private volatile boolean throwIdentity; + private volatile boolean unstableIdentity; + private volatile RedisCommandFailureException readFailure; + private volatile RedisCommandFailureException writeFailure; + private volatile byte[] readReply; + private volatile RedisCatalogProgramReply catalogReply; + private volatile RedisCommandFailureException.Certainty failureCertainty = + RedisCommandFailureException.Certainty.NOT_APPLIED; + + private FakeRuntime(String deploymentId) { + this(deploymentId, null); + } + + private FakeRuntime(String deploymentId, RedisRouteIdentity routeIdentity) { + this.deploymentId = deploymentId; + this.routeIdentity = + routeIdentity == null ? RedisRouteIdentity.opaqueRuntime(this) : routeIdentity; + } + + @Override + public void probe(Duration timeout) { + probes.incrementAndGet(); + probed.countDown(); + if (failProbe) { + throw new IllegalStateException("probe failed"); + } + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public RedisRouteIdentity routeIdentity() { + identityCalls.incrementAndGet(); + if (throwIdentity) { + throw new IllegalStateException("identity unavailable"); + } + if (nullIdentity) { + return null; + } + if (unstableIdentity) { + return RedisRouteIdentity.opaqueRuntime(new Object()); + } + return routeIdentity; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + reads.incrementAndGet(); + if (readFailure != null) { + throw readFailure; + } + if (failReads) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + failureCertainty, + "read unavailable", + null); + } + if (blockReads && reads.get() == 1) { + entered.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + return readReply == null ? deploymentId.getBytes(StandardCharsets.UTF_8) : readReply.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + writes.incrementAndGet(); + if (writeFailure != null) { + throw writeFailure; + } + } + + @Override + public long delete(RedisPhysicalKey key) { + return 1; + } + + @Override + public RedisPrimitiveReply execute(RedisPrimitiveInvocation invocation) { + primitiveCalls.incrementAndGet(); + return RedisPrimitiveReply.missing(); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + if (catalogReply != null) { + return catalogReply; + } + return switch (invocation.replyShape()) { + case MULTI, READ_ONLY_MULTI -> + RedisCatalogProgramReply.multi(List.of(deploymentId.getBytes(StandardCharsets.UTF_8))); + case VALUE, READ_ONLY_VALUE -> + RedisCatalogProgramReply.value(deploymentId.getBytes(StandardCharsets.UTF_8)); + }; + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + publishes.incrementAndGet(); + return 1; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + if (failSubscription) { + throw new IllegalStateException("subscription failed"); + } + invalidationListener = listener; + invalidationSubscriptionClosed = false; + return () -> invalidationSubscriptionClosed = true; + } + + private void emit(String message) { + if (!invalidationSubscriptionClosed && invalidationListener != null) { + invalidationListener.onMessage(message.getBytes(StandardCharsets.US_ASCII)); + } + } + + private void disconnect() { + if (invalidationListener != null) { + invalidationListener.onDisconnected(); + } + } + + @Override + public void close() { + closeCalls.incrementAndGet(); + closed = true; + } + } + + private static final class RecoveryRuntime implements RedisRoutableCommandRuntime { + + private final List trace = new java.util.ArrayList<>(); + private int executions; + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return "recovery"; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + trace.add("EVALSHA"); + executions++; + if (executions == 1) { + throw new RedisNoScriptException(); + } + return RedisCatalogProgramReply.value("recovered".getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + trace.add("SCRIPT_LOAD"); + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 0; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + return () -> {}; + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java index cbc4c2c..0c7e15d 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisRuntimeSettingsTest.java @@ -30,10 +30,49 @@ class RedisRuntimeSettingsTest { assertThat(settings.port()).isEqualTo(6379); assertThat(settings.hmacSecret()).hasSize(32); assertThat(settings.positiveTtl()).isEqualTo(Duration.ofMinutes(5)); + assertThat(settings.positiveSoftTtl()).isEqualTo(Duration.ofMinutes(4)); + assertThat(settings.ttlJitter()).isEqualTo(0.10d); + assertThat(settings.minimumHardTtl()).isEqualTo(Duration.ofSeconds(1)); assertThat(settings.maximumQueuedCommands()).isEqualTo(8); assertThat(settings.maximumInFlightBytes()).isEqualTo(16_777_216); } + @Test + void rejectsAnInvalidSoftHardTtlOrJitterPolicy() { + assertThatThrownBy( + () -> + settingsWithCachePolicy( + Duration.ofMinutes(6), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + 0.10d, + Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("soft TTL"); + + assertThatThrownBy( + () -> + settingsWithCachePolicy( + Duration.ofMinutes(4), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + 0.51d, + Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("jitter"); + + assertThatThrownBy( + () -> + settingsWithCachePolicy( + Duration.ofMinutes(4), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + 0.10d, + Duration.ofMinutes(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minimum hard TTL"); + } + @Test void enabledRuntimeRejectsShortOrMissingHmacSecret() { RedisRuntimeSettings settings = @@ -171,4 +210,31 @@ class RedisRuntimeSettingsTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("queued-command count"); } + + private static RedisRuntimeSettings settingsWithCachePolicy( + Duration positiveSoftTtl, + Duration positiveHardTtl, + Duration negativeTtl, + double ttlJitter, + Duration minimumHardTtl) { + return new RedisRuntimeSettings( + true, + RedisRuntimeSettings.ClientMode.MANAGED, + "localhost", + 6379, + "", + Base64.getEncoder().encodeToString(new byte[32]), + Duration.ofSeconds(2), + positiveHardTtl, + positiveSoftTtl, + negativeTtl, + ttlJitter, + minimumHardTtl, + "ca-skeleton", + "test", + "worklog", + 1024, + 8, + 16_777_216); + } } diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContractTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContractTest.java new file mode 100644 index 0000000..89bad6c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticAclScriptContractTest.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class RedisSemanticAclScriptContractTest { + + @Test + void runtimeScriptMustMatchCanonicalCommandToKeyPositions() { + Map surfaces = + Arrays.stream(Capability.values()) + .collect( + Collectors.toUnmodifiableMap( + capability -> capability, RedisSemanticAclSurface::forCapability)); + + assertThatCode( + () -> + RedisSemanticAclScriptContract.validate( + RedisSemanticAclProbeCatalog.scriptBytes(), surfaces)) + .doesNotThrowAnyException(); + + byte[] drifted = + new String(RedisSemanticAclProbeCatalog.scriptBytes(), UTF_8) + .replace("permitted('HGET', KEYS[2], 'field')", "permitted('HGET', KEYS[1], 'field')") + .getBytes(UTF_8); + assertThatThrownBy(() -> RedisSemanticAclScriptContract.validate(drifted, surfaces)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Redis semantic ACL Lua surface does not match its canonical mapping"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticOutcomeClassificationTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticOutcomeClassificationTest.java new file mode 100644 index 0000000..ca14998 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticOutcomeClassificationTest.java @@ -0,0 +1,221 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.cache.CacheLookup; +import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.IdempotencyFailOutcome; +import dev.caskeleton.application.idempotency.IdempotencyReleaseOutcome; +import dev.caskeleton.application.idempotency.IdempotencyRenewOutcome; +import dev.caskeleton.application.idempotency.IdempotencyStartOutcome; +import java.time.Instant; +import java.util.EnumSet; +import org.junit.jupiter.api.Test; + +class RedisSemanticOutcomeClassificationTest { + + private static final String OPERATION = "operation_token_12345"; + + @Test + void cacheDistinguishesFreshStaleMissPolicySkipCompatibilityAndFailureCertainty() { + Instant now = Instant.parse("2026-07-30T00:00:00Z"); + assertClassification( + RedisStringCacheRegion.classifyLookup( + new CacheLookup.Hit<>( + "value", + CacheLookup.Freshness.FRESH, + "revision", + now.plusSeconds(1), + now.plusSeconds(2))), + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisStringCacheRegion.classifyLookup( + new CacheLookup.Hit<>( + "value", + CacheLookup.Freshness.STALE, + "revision", + now.minusSeconds(1), + now.plusSeconds(2))), + RedisCapabilityObservationEvent.Outcome.STALE, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisStringCacheRegion.classifyLookup( + new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)), + RedisCapabilityObservationEvent.Outcome.MISS, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisStringCacheRegion.classifyLookup( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.FUTURE_VERSION, CacheLookup.SchemaPolicy.FAIL_FAST)), + RedisCapabilityObservationEvent.Outcome.INCOMPATIBLE, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisStringCacheRegion.classifyLookup( + new CacheLookup.Unavailable<>( + CacheLookup.UnavailabilityReason.UNAVAILABLE, + CacheLookup.OperationCertainty.INDETERMINATE)), + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE); + + assertClassification( + RedisStringCacheRegion.classifyRecord(CacheRecordOutcome.NOT_RECORDED_PROVIDER_POLICY), + RedisCapabilityObservationEvent.Outcome.SKIPPED, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + } + + @Test + void everyIdempotencyStartStatusHasAnExplicitNonLyingMapping() { + for (IdempotencyStartOutcome.Status status : IdempotencyStartOutcome.Status.values()) { + assertClassification( + RedisIdempotencyStoreProvider.classifyStart( + new IdempotencyStartOutcome(status, operation(status))), + switch (status) { + case STARTED, ALREADY_STARTED_SAME_OPERATION -> + RedisCapabilityObservationEvent.Outcome.SUCCESS; + case ABSENT -> RedisCapabilityObservationEvent.Outcome.MISS; + case NOT_OWNER -> RedisCapabilityObservationEvent.Outcome.DENIED; + case NOT_CLAIMED, OPERATION_CONFLICT -> + RedisCapabilityObservationEvent.Outcome.CONFLICT; + case INDETERMINATE -> RedisCapabilityObservationEvent.Outcome.INDETERMINATE; + case UNAVAILABLE -> RedisCapabilityObservationEvent.Outcome.UNAVAILABLE; + }, + certainty(status.name())); + } + } + + @Test + void everyIdempotencyRenewStatusHasAnExplicitNonLyingMapping() { + for (IdempotencyRenewOutcome.Status status : IdempotencyRenewOutcome.Status.values()) { + assertClassification( + RedisIdempotencyStoreProvider.classifyRenew( + new IdempotencyRenewOutcome(status, operation(status))), + switch (status) { + case RENEWED, ALREADY_RENEWED_SAME_OPERATION -> + RedisCapabilityObservationEvent.Outcome.SUCCESS; + case ABSENT -> RedisCapabilityObservationEvent.Outcome.MISS; + case NOT_OWNER -> RedisCapabilityObservationEvent.Outcome.DENIED; + case NOT_IN_PROGRESS, OPERATION_CONFLICT -> + RedisCapabilityObservationEvent.Outcome.CONFLICT; + case INDETERMINATE -> RedisCapabilityObservationEvent.Outcome.INDETERMINATE; + case UNAVAILABLE -> RedisCapabilityObservationEvent.Outcome.UNAVAILABLE; + }, + certainty(status.name())); + } + } + + @Test + void everyIdempotencyCompleteStatusHasAnExplicitNonLyingMapping() { + for (IdempotencyCompleteOutcome.Status status : IdempotencyCompleteOutcome.Status.values()) { + assertClassification( + RedisIdempotencyStoreProvider.classifyComplete( + new IdempotencyCompleteOutcome(status, operation(status))), + switch (status) { + case COMPLETED, ALREADY_COMPLETED_SAME_RESULT -> + RedisCapabilityObservationEvent.Outcome.SUCCESS; + case ABSENT -> RedisCapabilityObservationEvent.Outcome.MISS; + case NOT_OWNER -> RedisCapabilityObservationEvent.Outcome.DENIED; + case RESPONSE_CONFLICT, NOT_IN_PROGRESS, OPERATION_CONFLICT -> + RedisCapabilityObservationEvent.Outcome.CONFLICT; + case INDETERMINATE -> RedisCapabilityObservationEvent.Outcome.INDETERMINATE; + case UNAVAILABLE -> RedisCapabilityObservationEvent.Outcome.UNAVAILABLE; + }, + certainty(status.name())); + } + } + + @Test + void everyIdempotencyFailStatusHasAnExplicitNonLyingMapping() { + for (IdempotencyFailOutcome.Status status : IdempotencyFailOutcome.Status.values()) { + assertClassification( + RedisIdempotencyStoreProvider.classifyFail( + new IdempotencyFailOutcome(status, operation(status))), + switch (status) { + case MARKED_RETRYABLE, MARKED_ABANDONED, ALREADY_MARKED_SAME_OPERATION -> + RedisCapabilityObservationEvent.Outcome.SUCCESS; + case ABSENT -> RedisCapabilityObservationEvent.Outcome.MISS; + case NOT_OWNER -> RedisCapabilityObservationEvent.Outcome.DENIED; + case NOT_IN_PROGRESS, OPERATION_CONFLICT -> + RedisCapabilityObservationEvent.Outcome.CONFLICT; + case INDETERMINATE -> RedisCapabilityObservationEvent.Outcome.INDETERMINATE; + case UNAVAILABLE -> RedisCapabilityObservationEvent.Outcome.UNAVAILABLE; + }, + certainty(status.name())); + } + } + + @Test + void everyIdempotencyReleaseStatusHasAnExplicitNonLyingMapping() { + for (IdempotencyReleaseOutcome.Status status : IdempotencyReleaseOutcome.Status.values()) { + assertClassification( + RedisIdempotencyStoreProvider.classifyRelease( + new IdempotencyReleaseOutcome(status, operation(status))), + switch (status) { + case RELEASED_BEFORE_EXECUTION, ALREADY_RELEASED_SAME_OPERATION -> + RedisCapabilityObservationEvent.Outcome.SUCCESS; + case ABSENT -> RedisCapabilityObservationEvent.Outcome.MISS; + case NOT_OWNER -> RedisCapabilityObservationEvent.Outcome.DENIED; + case EXECUTION_ALREADY_STARTED, OPERATION_CONFLICT -> + RedisCapabilityObservationEvent.Outcome.CONFLICT; + case INDETERMINATE -> RedisCapabilityObservationEvent.Outcome.INDETERMINATE; + case UNAVAILABLE -> RedisCapabilityObservationEvent.Outcome.UNAVAILABLE; + }, + certainty(status.name())); + } + } + + @Test + void sessionInspectionDistinguishesEverySealedResult() { + Instant now = Instant.parse("2026-07-30T00:00:00Z"); + assertClassification( + RedisLuaVersionedSessionStore.classifyInspection( + new SessionInspectionOutcome.Live(new byte[] {1}, 1, now.plusSeconds(60), now)), + RedisCapabilityObservationEvent.Outcome.HIT, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisLuaVersionedSessionStore.classifyInspection(new SessionInspectionOutcome.Absent()), + RedisCapabilityObservationEvent.Outcome.MISS, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisLuaVersionedSessionStore.classifyInspection(new SessionInspectionOutcome.Tombstoned()), + RedisCapabilityObservationEvent.Outcome.TOMBSTONED, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisLuaVersionedSessionStore.classifyInspection( + new SessionInspectionOutcome.AbsoluteExpired()), + RedisCapabilityObservationEvent.Outcome.ABSOLUTE_EXPIRED, + RedisCapabilityObservationEvent.Certainty.DEFINITE); + assertClassification( + RedisLuaVersionedSessionStore.classifyInspection( + new SessionInspectionOutcome.Unavailable()), + RedisCapabilityObservationEvent.Outcome.UNAVAILABLE, + RedisCapabilityObservationEvent.Certainty.NOT_APPLIED); + + assertThat(EnumSet.allOf(RedisCapabilityObservationEvent.Outcome.class)) + .contains( + RedisCapabilityObservationEvent.Outcome.STALE, + RedisCapabilityObservationEvent.Outcome.SKIPPED, + RedisCapabilityObservationEvent.Outcome.TOMBSTONED, + RedisCapabilityObservationEvent.Outcome.ABSOLUTE_EXPIRED); + } + + private static String operation(Enum status) { + return "INDETERMINATE".equals(status.name()) ? OPERATION : null; + } + + private static RedisCapabilityObservationEvent.Certainty certainty(String status) { + return switch (status) { + case "INDETERMINATE" -> RedisCapabilityObservationEvent.Certainty.INDETERMINATE; + case "UNAVAILABLE" -> RedisCapabilityObservationEvent.Certainty.NOT_APPLIED; + default -> RedisCapabilityObservationEvent.Certainty.DEFINITE; + }; + } + + private static void assertClassification( + RedisCapabilityObserver.Classification actual, + RedisCapabilityObservationEvent.Outcome outcome, + RedisCapabilityObservationEvent.Certainty certainty) { + assertThat(actual).isEqualTo(new RedisCapabilityObserver.Classification(outcome, certainty)); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeManifestContractTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeManifestContractTest.java new file mode 100644 index 0000000..8f2f4cc --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeManifestContractTest.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RedisSemanticProbeManifestContractTest { + + @Test + void healthNamespaceTtlCommonAclAndCapabilityProgramsMatchTheRuntimePlan() throws IOException { + String manifest = resource("redis/semantic-readiness-contract.json"); + + assertThat(JsonPath.read(manifest, "$.schemaVersion").intValue()).isEqualTo(1); + assertThat(JsonPath.read(manifest, "$.namespacePrefix")) + .isEqualTo(RedisSemanticProbePlan.KEY_NAMESPACE_PREFIX); + assertThat(JsonPath.read(manifest, "$.aclKeyPattern")) + .isEqualTo(RedisSemanticProbePlan.ACL_KEY_PATTERN); + assertThat(JsonPath.read(manifest, "$.maximumTtlMillis").longValue()) + .isEqualTo(RedisSemanticProbePlan.MAXIMUM_TTL.toMillis()); + assertThat(JsonPath.>read(manifest, "$.commonAclCommands")) + .containsExactlyInAnyOrder("PING", "GET", "SET", "DEL", "EVALSHA", "SCRIPT|LOAD"); + + Map programs = JsonPath.read(manifest, "$.capabilityPrograms"); + assertThat(programs) + .containsExactlyInAnyOrderEntriesOf( + Map.of( + Capability.CACHE.name(), RedisProgramId.SET_IF_ABSENT_WITH_TTL.externalId(), + Capability.RATE_LIMIT.name(), RedisProgramId.RATE_FIXED_WINDOW_V2.externalId(), + Capability.IDEMPOTENCY.name(), RedisProgramId.IDEMPOTENCY_CLAIM_V1.externalId(), + Capability.EFFICIENCY_LEASE.name(), RedisProgramId.LEASE_ACQUIRE_V1.externalId(), + Capability.SESSION.name(), RedisProgramId.SESSION_CREATE_V1.externalId())); + + Map aclProbe = JsonPath.read(manifest, "$.aclProbeProgram"); + assertThat(aclProbe.keySet()) + .containsExactlyInAnyOrder( + "id", + "scriptResource", + "sha256", + "minimumRedisVersion", + "argumentCount", + "resultSchema", + "capabilityAclSurfaces"); + assertThat(aclProbe.get("id")).isEqualTo("semantic-capability-acl-v1"); + assertThat(aclProbe.get("scriptResource")) + .isEqualTo(RedisSemanticAclProbeCatalog.SCRIPT_RESOURCE); + assertThat(aclProbe.get("sha256")).isEqualTo(RedisSemanticAclProbeCatalog.sha256()); + assertThat(aclProbe.get("minimumRedisVersion")).isEqualTo("7.2"); + assertThat(((Number) aclProbe.get("argumentCount")).intValue()).isEqualTo(1); + Map resultSchema = map(aclProbe, "resultSchema"); + assertThat(resultSchema).containsEntry("fieldCount", 1).containsEntry("maximumFieldBytes", 32); + assertThat(Set.copyOf(strings(resultSchema, "statuses"))) + .containsExactlyInAnyOrder("ACL_OK", "ACL_DENIED", "VERSION_UNSUPPORTED", "INVALID"); + + Map surfaces = + JsonPath.read(manifest, "$.aclProbeProgram.capabilityAclSurfaces"); + assertThat(strings(map(surfaces, Capability.RATE_LIMIT.name()), "keyNames")) + .containsExactly("stateKey", "dedupHashKey", "dedupOrderKey"); + assertThat(integerLists(map(surfaces, Capability.RATE_LIMIT.name()), "commandKeyPositions")) + .containsEntry("TYPE", java.util.List.of(1, 2, 3)) + .containsEntry("HSET", java.util.List.of(1, 2)) + .containsEntry("HGET", java.util.List.of(2)) + .containsEntry("PEXPIRE", java.util.List.of(1, 2, 3)) + .containsEntry("ZADD", java.util.List.of(3)); + assertThat(strings(map(surfaces, Capability.SESSION.name()), "keyNames")) + .containsExactly("liveSessionKey", "tombstoneKey"); + assertThat(integerLists(map(surfaces, Capability.SESSION.name()), "commandKeyPositions")) + .containsEntry("EXISTS", java.util.List.of(1, 2)) + .containsEntry("HMGET", java.util.List.of(1)) + .containsEntry("HSET", java.util.List.of(1)) + .containsEntry("PEXPIRE", java.util.List.of(1)); + + for (Capability capability : Capability.values()) { + Map surface = map(surfaces, capability.name()); + RedisSemanticAclSurface canonical = RedisSemanticAclSurface.forCapability(capability); + RedisProgramDescriptor representative = + RedisProgramCatalog.unified() + .descriptor(RedisSemanticProbePlan.representativeProgram(capability)); + assertThat(strings(surface, "keyNames")) + .as("key positions for %s", capability) + .containsExactlyElementsOf( + representative.contract().keys().stream() + .map(RedisProgramContract.Input::name) + .toList()); + assertThat(strings(surface, "keyNames")).containsExactlyElementsOf(canonical.keyNames()); + assertThat(integerLists(surface, "commandKeyPositions")) + .isEqualTo(canonical.commandKeyPositions()); + assertThat(canonical.commandKeyPositions().keySet()) + .as("command surface for %s", capability) + .isEqualTo(representative.contract().aclCommands()); + } + } + + private static String resource(String name) throws IOException { + try (InputStream input = + RedisSemanticProbeManifestContractTest.class.getClassLoader().getResourceAsStream(name)) { + assertThat(input).as("manifest resource %s", name).isNotNull(); + return new String(input.readAllBytes(), UTF_8); + } + } + + @SuppressWarnings("unchecked") + private static Map map(Map source, String field) { + return (Map) source.get(field); + } + + @SuppressWarnings("unchecked") + private static java.util.List strings(Map source, String field) { + return (java.util.List) source.get(field); + } + + @SuppressWarnings("unchecked") + private static Map> integerLists( + Map source, String field) { + return (Map>) source.get(field); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCacheTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCacheTest.java new file mode 100644 index 0000000..5b35318 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticProbeObservationCacheTest.java @@ -0,0 +1,375 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RedisSemanticProbeObservationCacheTest { + + @Test + void startupSeedPreventsFirstParallelHealthCallFromReportingProbeInProgress() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), Duration.ofSeconds(15), clock, ticker::read); + cache.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + + CountDownLatch probeStarted = new CountDownLatch(1); + CountDownLatch releaseProbe = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + clock.advance(Duration.ofSeconds(6)); + ticker.advance(Duration.ofSeconds(6)); + var leader = + executor.submit( + () -> + cache.observe( + () -> { + probeStarted.countDown(); + await(releaseProbe); + return Reason.SEMANTIC_PROBE_SUCCEEDED; + })); + assertThat(probeStarted.await(1, TimeUnit.SECONDS)).isTrue(); + + RedisSemanticProbeObservationCache.Observation follower = + cache.observe( + () -> { + throw new AssertionError("follower must not execute the probe"); + }); + + assertThat(follower.reason()).isEqualTo(Reason.SEMANTIC_PROBE_SUCCEEDED); + assertThat(follower.observedAt()).isEqualTo(Instant.parse("2026-07-29T00:00:00Z")); + assertThat(follower.age()).isEqualTo(Duration.ofSeconds(6)); + assertThat(follower.stale()).isTrue(); + releaseProbe.countDown(); + assertThat(leader.get(1, TimeUnit.SECONDS).stale()).isFalse(); + } + } + + @Test + void followerWithoutSeedAndExpiredSeedReturnSanitizedUnavailableReasons() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), Duration.ofSeconds(15), clock, ticker::read); + CountDownLatch probeStarted = new CountDownLatch(1); + CountDownLatch releaseProbe = new CountDownLatch(1); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var initialLeader = + executor.submit( + () -> + cache.observe( + () -> { + probeStarted.countDown(); + await(releaseProbe); + return Reason.SEMANTIC_PROBE_SUCCEEDED; + })); + assertThat(probeStarted.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(cache.observe(() -> Reason.SEMANTIC_READ_WRITE_FAILED).reason()) + .isEqualTo(Reason.SEMANTIC_PROBE_IN_PROGRESS); + releaseProbe.countDown(); + initialLeader.get(1, TimeUnit.SECONDS); + } + + clock.advance(Duration.ofSeconds(16)); + ticker.advance(Duration.ofSeconds(16)); + CountDownLatch refreshStarted = new CountDownLatch(1); + CountDownLatch releaseRefresh = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var refreshLeader = + executor.submit( + () -> + cache.observe( + () -> { + refreshStarted.countDown(); + await(releaseRefresh); + return Reason.SEMANTIC_PROBE_SUCCEEDED; + })); + assertThat(refreshStarted.await(1, TimeUnit.SECONDS)).isTrue(); + + RedisSemanticProbeObservationCache.Observation follower = + cache.observe(() -> Reason.SEMANTIC_READ_WRITE_FAILED); + + assertThat(follower.reason()).isEqualTo(Reason.SEMANTIC_OBSERVATION_STALE); + assertThat(follower.age()).isEqualTo(Duration.ofSeconds(16)); + assertThat(follower.stale()).isTrue(); + releaseRefresh.countDown(); + refreshLeader.get(1, TimeUnit.SECONDS); + } + } + + @Test + void wallClockJumpsDoNotChangeCadenceOrMonotonicAge() { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), Duration.ofSeconds(15), clock, ticker::read); + AtomicInteger probes = new AtomicInteger(); + cache.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + + clock.advance(Duration.ofDays(365)); + ticker.advance(Duration.ofSeconds(1)); + assertThat(cache.observe(() -> probeCount(probes)).age()).isEqualTo(Duration.ofSeconds(1)); + assertThat(probes).hasValue(0); + + clock.advance(Duration.ofDays(-730)); + ticker.advance(Duration.ofSeconds(5)); + RedisSemanticProbeObservationCache.Observation refreshed = + cache.observe(() -> probeCount(probes)); + assertThat(refreshed.reason()).isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(refreshed.observedAt()).isEqualTo(Instant.parse("2025-07-29T00:00:00Z")); + assertThat(probes).hasValue(1); + } + + @Test + void thirtyTwoConcurrentCallersExecuteOneProbeAndFailureIsCachedUntilMinimumInterval() + throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), Duration.ofSeconds(15), clock, ticker::read); + cache.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + ticker.advance(Duration.ofSeconds(6)); + CountDownLatch probeStarted = new CountDownLatch(1); + CountDownLatch releaseProbe = new CountDownLatch(1); + AtomicInteger probes = new AtomicInteger(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var callers = + java.util.stream.IntStream.range(0, 32) + .mapToObj( + ignored -> + executor.submit( + () -> + cache.observe( + () -> { + probes.incrementAndGet(); + probeStarted.countDown(); + await(releaseProbe); + return Reason.SEMANTIC_READ_WRITE_FAILED; + }))) + .toList(); + assertThat(probeStarted.await(1, TimeUnit.SECONDS)).isTrue(); + releaseProbe.countDown(); + for (var caller : callers) { + caller.get(1, TimeUnit.SECONDS); + } + } + + assertThat(probes).hasValue(1); + ticker.advance(Duration.ofSeconds(4)); + assertThat(cache.observe(() -> probeCount(probes)).reason()) + .isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(probes).hasValue(1); + ticker.advance(Duration.ofSeconds(1)); + assertThat(cache.observe(() -> probeCount(probes)).reason()) + .isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(probes).hasValue(2); + } + + @Test + void delayedStaleCallerRechecksFreshObservationAfterAcquiringRefreshClaim() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(); + CountDownLatch delayedCallerReachedClaim = new CountDownLatch(1); + CountDownLatch releaseDelayedCaller = new CountDownLatch(1); + AtomicReference delayedCallerThread = new AtomicReference<>(); + AtomicInteger probes = new AtomicInteger(); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), + Duration.ofSeconds(15), + clock, + ticker::read, + () -> { + if (Thread.currentThread() == delayedCallerThread.get()) { + delayedCallerReachedClaim.countDown(); + await(releaseDelayedCaller); + } + }); + cache.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + ticker.advance(Duration.ofSeconds(6)); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var delayed = + executor.submit( + () -> { + delayedCallerThread.set(Thread.currentThread()); + return cache.observe(() -> probeCount(probes)); + }); + assertThat(delayedCallerReachedClaim.await(1, TimeUnit.SECONDS)).isTrue(); + + RedisSemanticProbeObservationCache.Observation leader = + cache.observe(() -> probeCount(probes)); + assertThat(leader.reason()).isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(probes).hasValue(1); + + releaseDelayedCaller.countDown(); + RedisSemanticProbeObservationCache.Observation follower = delayed.get(1, TimeUnit.SECONDS); + assertThat(follower.reason()).isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(follower.age()).isZero(); + assertThat(follower.stale()).isFalse(); + assertThat(probes).hasValue(1); + } + } + + @Test + void followerReobservesFreshLeaderResultWithCurrentTickAfterFailedRefreshClaim() + throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(); + CountDownLatch delayedCallerReachedClaim = new CountDownLatch(1); + CountDownLatch releaseDelayedCaller = new CountDownLatch(1); + CountDownLatch leaderStoredObservation = new CountDownLatch(1); + CountDownLatch releaseLeader = new CountDownLatch(1); + AtomicReference delayedCallerThread = new AtomicReference<>(); + AtomicInteger probes = new AtomicInteger(); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), + Duration.ofSeconds(15), + clock, + ticker::read, + () -> { + if (Thread.currentThread() == delayedCallerThread.get()) { + delayedCallerReachedClaim.countDown(); + await(releaseDelayedCaller); + } + }, + () -> { + leaderStoredObservation.countDown(); + await(releaseLeader); + }); + cache.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + ticker.advance(Duration.ofSeconds(6)); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var delayed = + executor.submit( + () -> { + delayedCallerThread.set(Thread.currentThread()); + return cache.observe(() -> probeCount(probes)); + }); + assertThat(delayedCallerReachedClaim.await(1, TimeUnit.SECONDS)).isTrue(); + + ticker.advance(Duration.ofSeconds(1)); + var leader = executor.submit(() -> cache.observe(() -> probeCount(probes))); + assertThat(leaderStoredObservation.await(1, TimeUnit.SECONDS)).isTrue(); + + releaseDelayedCaller.countDown(); + RedisSemanticProbeObservationCache.Observation follower = delayed.get(1, TimeUnit.SECONDS); + assertThat(follower.reason()).isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(follower.age()).isZero(); + assertThat(follower.stale()).isFalse(); + assertThat(probes).hasValue(1); + + releaseLeader.countDown(); + assertThat(leader.get(1, TimeUnit.SECONDS).reason()) + .isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + } + } + + @Test + void nanoTickerWrapAroundPreservesElapsedAndBackwardTickerForcesRefresh() { + MutableClock clock = new MutableClock(Instant.parse("2026-07-29T00:00:00Z")); + MutableTicker ticker = new MutableTicker(Long.MAX_VALUE - Duration.ofSeconds(2).toNanos()); + RedisSemanticProbeObservationCache cache = + new RedisSemanticProbeObservationCache( + Duration.ofSeconds(5), Duration.ofSeconds(15), clock, ticker::read); + AtomicInteger probes = new AtomicInteger(); + cache.seed(Reason.SEMANTIC_PROBE_SUCCEEDED); + + ticker.advance(Duration.ofSeconds(3)); + assertThat(cache.observe(() -> probeCount(probes)).age()).isEqualTo(Duration.ofSeconds(3)); + assertThat(probes).hasValue(0); + + ticker.set(ticker.read() - Duration.ofSeconds(4).toNanos()); + assertThat(cache.observe(() -> probeCount(probes)).reason()) + .isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(probes).hasValue(1); + } + + private static Reason probeCount(AtomicInteger probes) { + probes.incrementAndGet(); + return Reason.SEMANTIC_READ_WRITE_FAILED; + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(1, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting for test latch"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for test latch", exception); + } + } + + private static final class MutableClock extends Clock { + + private final AtomicLong epochMillis; + + private MutableClock(Instant initial) { + this.epochMillis = new AtomicLong(initial.toEpochMilli()); + } + + private void advance(Duration duration) { + epochMillis.addAndGet(duration.toMillis()); + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return Instant.ofEpochMilli(epochMillis.get()); + } + } + + private static final class MutableTicker { + + private final AtomicLong nanos; + + private MutableTicker() { + this(0L); + } + + private MutableTicker(long initial) { + nanos = new AtomicLong(initial); + } + + private long read() { + return nanos.get(); + } + + private void advance(Duration duration) { + nanos.addAndGet(duration.toNanos()); + } + + private void set(long value) { + nanos.set(value); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbeTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbeTest.java new file mode 100644 index 0000000..a8534db --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSemanticReadinessProbeTest.java @@ -0,0 +1,563 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class RedisSemanticReadinessProbeTest { + + private static final Instant OBSERVED_AT = Instant.parse("2026-07-29T01:02:03Z"); + private static final Clock CLOCK = Clock.fixed(OBSERVED_AT, ZoneOffset.UTC); + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "semantic-health-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void rolePlanIsImmutableAndKeepsOneRepresentativeProgramPerSelectedCapability() { + EnumSet selected = EnumSet.of(Capability.RATE_LIMIT, Capability.IDEMPOTENCY); + + RedisSemanticProbePlan plan = RedisSemanticProbePlan.forRole(RedisRole.COORDINATION, selected); + selected.add(Capability.EFFICIENCY_LEASE); + + assertThat(plan.capabilities()) + .containsExactlyInAnyOrder(Capability.RATE_LIMIT, Capability.IDEMPOTENCY); + assertThat(plan.representativePrograms()) + .containsExactly(RedisProgramId.RATE_FIXED_WINDOW_V2, RedisProgramId.IDEMPOTENCY_CLAIM_V1); + assertThat(plan.commonReadWrite()).isTrue(); + assertThatThrownBy(() -> plan.capabilities().add(Capability.EFFICIENCY_LEASE)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> plan.representativePrograms().add(RedisProgramId.LEASE_ACQUIRE_V1)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void pingSuccessCannotHideASelectedProgramAclDenialOrLeakFailureDetail() { + SemanticRuntime runtime = new SemanticRuntime(); + runtime.denyScriptLoad( + new RedisCommandFailureException( + RedisCommandFailureException.Kind.ACL_DENIED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "NOPERM app-user secret-value coord.internal ca-health:{raw-key}", + new IllegalStateException("server credential detail"))); + + assertThatThrownBy( + () -> registry(runtime, Set.of(Capability.RATE_LIMIT, Capability.IDEMPOTENCY), 4)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(Reason.SEMANTIC_PROGRAM_ACL_DENIED.name()) + .hasMessageNotContaining("NOPERM") + .hasMessageNotContaining("app-user") + .hasMessageNotContaining("secret-value") + .hasMessageNotContaining("coord.internal") + .hasMessageNotContaining("raw-key") + .hasMessageNotContaining("IllegalStateException"); + assertThat(runtime.pings()).isEqualTo(1); + } + + @Test + void serverBelowTheCatalogMinimumFailsBeforeRepresentativeProgramExecution() { + SemanticRuntime runtime = new SemanticRuntime(); + runtime.aclStatus("VERSION_UNSUPPORTED"); + RedisRoleCommandRouter router = router(runtime, 4); + RedisSemanticReadinessProbe probe = + new RedisSemanticReadinessProbe( + RedisProgramCatalog.unified(), CLOCK, () -> "abcdefghijklmnopqrstuv"); + try (router) { + Reason reason = + probe.probe( + RedisSemanticProbePlan.forRole(RedisRole.CACHE, Set.of(Capability.CACHE)), router); + + assertThat(reason).isEqualTo(Reason.SERVER_VERSION_UNSUPPORTED); + assertThat(runtime.semanticEvents()).containsExactly("ACL:CACHE"); + assertThat(runtime.remainingKeys()).isEmpty(); + } + } + + @Test + void oneCommonReadWriteAndEverySelectedProgramUseBoundedEphemeralKeysThenCleanUp() { + SemanticRuntime runtime = new SemanticRuntime(); + RedisRoleCommandRouter router = router(runtime, 4); + RedisSemanticReadinessProbe probe = + new RedisSemanticReadinessProbe( + RedisProgramCatalog.unified(), CLOCK, () -> "abcdefghijklmnopqrstuv"); + RedisSemanticProbePlan plan = + RedisSemanticProbePlan.forRole( + RedisRole.COORDINATION, + Set.of(Capability.RATE_LIMIT, Capability.IDEMPOTENCY, Capability.EFFICIENCY_LEASE)); + try (router) { + Reason reason = probe.probe(plan, router); + + assertThat(reason).isEqualTo(Reason.SEMANTIC_PROBE_SUCCEEDED); + assertThat(runtime.commonReadWriteSets()).isEqualTo(1); + assertThat(runtime.executedPrograms()) + .containsExactlyInAnyOrder( + RedisProgramId.RATE_FIXED_WINDOW_V2, + RedisProgramId.IDEMPOTENCY_CLAIM_V1, + RedisProgramId.LEASE_ACQUIRE_V1); + assertThat(runtime.semanticEvents()) + .containsExactly( + "ACL:RATE_LIMIT", + "PROGRAM:RATE_FIXED_WINDOW_V2", + "ACL:IDEMPOTENCY", + "PROGRAM:IDEMPOTENCY_CLAIM_V1", + "ACL:EFFICIENCY_LEASE", + "PROGRAM:LEASE_ACQUIRE_V1"); + assertThat(runtime.aclKeys(Capability.RATE_LIMIT)) + .hasSize(3) + .isEqualTo(runtime.programKeys(RedisProgramId.RATE_FIXED_WINDOW_V2)); + assertThat(runtime.aclKeys(Capability.IDEMPOTENCY)) + .hasSize(1) + .isEqualTo(runtime.programKeys(RedisProgramId.IDEMPOTENCY_CLAIM_V1)); + assertThat(runtime.aclKeys(Capability.EFFICIENCY_LEASE)) + .hasSize(1) + .isEqualTo(runtime.programKeys(RedisProgramId.LEASE_ACQUIRE_V1)); + assertThat(runtime.everyProgramKeyHadBoundedTtlBeforeExecution()).isTrue(); + assertThat(runtime.timedWrites()).isNotEmpty(); + assertThat(runtime.timedWrites()) + .allSatisfy( + write -> { + assertThat(write.key()) + .startsWith(RedisSemanticProbePlan.KEY_NAMESPACE_PREFIX) + .doesNotContain("user", "session-id", "credential"); + assertThat(write.key().getBytes(UTF_8).length).isLessThanOrEqualTo(512); + assertThat(write.ttl()) + .isPositive() + .isLessThanOrEqualTo(RedisSemanticProbePlan.MAXIMUM_TTL); + }); + assertThat(runtime.remainingKeys()).isEmpty(); + } + } + + @Test + void partialProgramMutationAndDeniedCleanupCannotLeaveAnImmortalProbeKey() { + SemanticRuntime runtime = new SemanticRuntime(); + runtime.failProgramAfterPartialMutation( + new RedisCommandFailureException( + RedisCommandFailureException.Kind.ACL_DENIED, + RedisCommandFailureException.Certainty.INDETERMINATE, + "NOPERM pexpire denied after hash mutation", + null)); + runtime.denyCleanup(); + RedisRoleCommandRouter router = router(runtime, 4); + RedisSemanticReadinessProbe probe = + new RedisSemanticReadinessProbe( + RedisProgramCatalog.unified(), CLOCK, () -> "abcdefghijklmnopqrstuv"); + try (router) { + Reason reason = + probe.probe( + RedisSemanticProbePlan.forRole( + RedisRole.COORDINATION, Set.of(Capability.IDEMPOTENCY)), + router); + + assertThat(reason).isEqualTo(Reason.SEMANTIC_PROGRAM_ACL_DENIED); + assertThat(runtime.remainingKeys()).isNotEmpty(); + assertThat(runtime.remainingKeys()) + .allSatisfy( + key -> { + Duration ttl = runtime.remainingTtl(key).orElseThrow(); + assertThat(ttl) + .isGreaterThan(Duration.ZERO) + .isLessThanOrEqualTo(RedisSemanticProbePlan.MAXIMUM_TTL); + }); + } + } + + @Test + void readFailureAfterWriteIsSanitizedAndStillCleansTheEphemeralKey() { + SemanticRuntime runtime = new SemanticRuntime(); + runtime.failNextGet( + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "raw value and server endpoint", + null)); + RedisRoleCommandRouter router = router(runtime, 4); + RedisSemanticReadinessProbe probe = + new RedisSemanticReadinessProbe( + RedisProgramCatalog.unified(), CLOCK, () -> "abcdefghijklmnopqrstuv"); + try (router) { + Reason reason = + probe.probe( + RedisSemanticProbePlan.forRole(RedisRole.CACHE, Set.of(Capability.CACHE)), router); + + assertThat(reason).isEqualTo(Reason.SEMANTIC_READ_WRITE_FAILED); + assertThat(runtime.remainingKeys()).isEmpty(); + } + } + + @Test + void saturationRecentCommandFailureAndClosedRouteHaveDistinctSanitizedReasons() throws Exception { + RedisSemanticReadinessProbe probe = + new RedisSemanticReadinessProbe( + RedisProgramCatalog.unified(), CLOCK, () -> "abcdefghijklmnopqrstuv"); + RedisSemanticProbePlan plan = + RedisSemanticProbePlan.forRole(RedisRole.CACHE, Set.of(Capability.CACHE)); + + SemanticRuntime recentRuntime = new SemanticRuntime(); + RedisRoleCommandRouter recentRouter = router(recentRuntime, 4); + try (recentRouter) { + recentRuntime.failNextGet( + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "recent raw server error", + null)); + assertThatThrownBy( + () -> recentRouter.get(RedisPhysicalKeyTestFactory.fromUtf8("ordinary-key"))) + .isInstanceOf(RedisCommandFailureException.class); + + assertThat(probe.probe(plan, recentRouter)).isEqualTo(Reason.RECENT_COMMAND_FAILURE); + } + + SemanticRuntime saturatedRuntime = new SemanticRuntime(); + saturatedRuntime.blockGet(); + RedisRoleCommandRouter saturatedRouter = router(saturatedRuntime, 1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (saturatedRouter) { + Future blockedCall = + executor.submit(() -> saturatedRouter.get(RedisPhysicalKeyTestFactory.fromUtf8("busy"))); + assertThat(saturatedRuntime.awaitBlockedGet()).isTrue(); + + assertThat(probe.probe(plan, saturatedRouter)).isEqualTo(Reason.COMMAND_SATURATED); + saturatedRuntime.releaseGet(); + assertThat(blockedCall.get(5, TimeUnit.SECONDS)).isNull(); + } finally { + saturatedRuntime.releaseGet(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + + SemanticRuntime closedRuntime = new SemanticRuntime(); + RedisRoleCommandRouter closedRouter = router(closedRuntime, 4); + closedRouter.close(); + + assertThat(probe.probe(plan, closedRouter)).isEqualTo(Reason.ROUTE_CLOSED); + } + + private static RedisCanonicalRoleRegistry registry( + SemanticRuntime runtime, Set capabilities, int maximumInFlight) { + return new RedisCanonicalRoleRegistry( + Map.of(RedisRole.COORDINATION, standalone("coord-main")), + CLIENT_SETTINGS, + maximumInFlight, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> runtime, + Map.of(RedisRole.COORDINATION, new RedisRoleBinding("coord-main", true, "noeviction")), + Map.of(RedisRole.COORDINATION, capabilities), + CLOCK); + } + + private static RedisRoleCommandRouter router(SemanticRuntime runtime, int maximumInFlight) { + return new RedisRoleCommandRouter( + runtime, maximumInFlight, 16_384, 1_048_576, Duration.ofSeconds(1), Duration.ofMinutes(5)); + } + + private static RedisDeploymentSettings.Standalone standalone(String id) { + return new RedisDeploymentSettings.Standalone( + id, + 0, + List.of(new RedisDeploymentSettings.Endpoint(id + ".internal", 6379)), + new RedisDeploymentSettings.Authentication( + "runtime", "secret://environment/REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/REDIS_TRUST_PEM")); + } + + private static final class SemanticRuntime implements RedisRoutableCommandRuntime { + + private final RedisProgramCatalog catalog = RedisProgramCatalog.unified(); + private final Map programsBySha = new HashMap<>(); + private final Map values = new HashMap<>(); + private final Map activeTtls = new HashMap<>(); + private final List timedWrites = new ArrayList<>(); + private final Set loadedPrograms = new HashSet<>(); + private final List executedPrograms = new ArrayList<>(); + private final Map> programKeys = new HashMap<>(); + private final Map> aclKeys = new HashMap<>(); + private final List semanticEvents = new ArrayList<>(); + private final CountDownLatch blockedGet = new CountDownLatch(1); + private final CountDownLatch releaseGet = new CountDownLatch(1); + private RuntimeException scriptLoadFailure; + private RuntimeException nextGetFailure; + private RuntimeException partialProgramFailure; + private String aclStatus = "ACL_OK"; + private boolean blockGet; + private boolean denyCleanup; + private boolean everyProgramKeyPreseeded = true; + private int pings; + private int commonReadWriteSets; + + private SemanticRuntime() { + catalog + .descriptors() + .forEach( + descriptor -> + programsBySha.put( + RedisScriptRecovery.sha1(descriptor.scriptBytes()), descriptor.id())); + } + + private void denyScriptLoad(RuntimeException failure) { + scriptLoadFailure = failure; + } + + private void failNextGet(RuntimeException failure) { + nextGetFailure = failure; + } + + private void failProgramAfterPartialMutation(RuntimeException failure) { + partialProgramFailure = failure; + } + + private void denyCleanup() { + denyCleanup = true; + } + + private void aclStatus(String status) { + aclStatus = status; + } + + private void blockGet() { + blockGet = true; + } + + private boolean awaitBlockedGet() throws InterruptedException { + return blockedGet.await(5, TimeUnit.SECONDS); + } + + private void releaseGet() { + releaseGet.countDown(); + } + + private int pings() { + return pings; + } + + private int commonReadWriteSets() { + return commonReadWriteSets; + } + + private List executedPrograms() { + return List.copyOf(executedPrograms); + } + + private List programKeys(RedisProgramId program) { + return programKeys.get(program); + } + + private List aclKeys(Capability capability) { + return aclKeys.get(capability); + } + + private List semanticEvents() { + return List.copyOf(semanticEvents); + } + + private List timedWrites() { + return List.copyOf(timedWrites); + } + + private Set remainingKeys() { + return Set.copyOf(values.keySet()); + } + + private java.util.Optional remainingTtl(String key) { + return java.util.Optional.ofNullable(activeTtls.get(key)); + } + + private boolean everyProgramKeyHadBoundedTtlBeforeExecution() { + return everyProgramKeyPreseeded; + } + + @Override + public void probe(Duration timeout) { + pings++; + } + + @Override + public String deploymentId() { + return "semantic-runtime"; + } + + @Override + public synchronized byte[] get(RedisPhysicalKey key) { + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + if (blockGet) { + blockedGet.countDown(); + try { + if (!releaseGet.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("test get release timed out"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test get interrupted"); + } finally { + blockGet = false; + } + } + if (nextGetFailure != null) { + RuntimeException failure = nextGetFailure; + nextGetFailure = null; + throw failure; + } + byte[] value = values.get(text(encodedKey)); + return value == null ? null : value.clone(); + } + + @Override + public synchronized void set( + RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + if (text(encodedKey).endsWith(":rw")) { + commonReadWriteSets++; + } + recordWrite(encodedKey, value.copyEncoded(), timeToLive); + } + + @Override + public synchronized long delete(RedisPhysicalKey key) { + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + if (denyCleanup) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.ACL_DENIED, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "cleanup denied", + null); + } + activeTtls.remove(text(encodedKey)); + return values.remove(text(encodedKey)) == null ? 0 : 1; + } + + @Override + public synchronized RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + String sha1 = invocation.sha1(); + List keys = RedisCatalogProgramInvocation.WireCodec.keys(invocation); + List arguments = RedisCatalogProgramInvocation.WireCodec.arguments(invocation); + requireLoaded(sha1); + if (sha1.equals(RedisScriptRecovery.sha1(RedisSemanticAclProbeCatalog.scriptBytes()))) { + observePreseeded(keys); + Capability capability = Capability.valueOf(text(arguments.getFirst())); + aclKeys.put(capability, keys.stream().map(SemanticRuntime::text).toList()); + semanticEvents.add("ACL:" + capability.name()); + return RedisCatalogProgramReply.value(aclStatus.getBytes(US_ASCII)); + } + RedisProgramId id = programsBySha.get(sha1); + observePreseeded(keys); + if (invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.MULTI) { + if (id != RedisProgramId.SET_IF_ABSENT_WITH_TTL) { + throw new AssertionError("unexpected scalar semantic program " + id); + } + executedPrograms.add(id); + programKeys.put(id, keys.stream().map(SemanticRuntime::text).toList()); + semanticEvents.add("PROGRAM:" + id.name()); + return RedisCatalogProgramReply.value("EXISTS".getBytes(US_ASCII)); + } + if (partialProgramFailure != null) { + values.put(text(keys.getFirst()), "partial".getBytes(US_ASCII)); + RuntimeException failure = partialProgramFailure; + partialProgramFailure = null; + throw failure; + } + executedPrograms.add(id); + programKeys.put(id, keys.stream().map(SemanticRuntime::text).toList()); + semanticEvents.add("PROGRAM:" + id.name()); + List fields = + switch (id) { + case RATE_FIXED_WINDOW_V2 -> + asciiFields("STATE_INCOMPATIBLE", "NONE", "1", "1", "1", "0", "0", "0"); + case IDEMPOTENCY_CLAIM_V1 -> asciiFields("STATE_INCOMPATIBLE", "0", "0", "-", "-", "-"); + case LEASE_ACQUIRE_V1 -> asciiFields("STATE_INCOMPATIBLE", "0", "1", "0", "0", "-"); + case SESSION_CREATE_V1 -> asciiFields("TOMBSTONED"); + default -> throw new AssertionError("unexpected structured semantic program " + id); + }; + return RedisCatalogProgramReply.multi(fields); + } + + @Override + public synchronized String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + if (scriptLoadFailure != null) { + throw scriptLoadFailure; + } + String sha1 = invocation.sha1(); + assertThat( + programsBySha.containsKey(sha1) + || sha1.equals( + RedisScriptRecovery.sha1(RedisSemanticAclProbeCatalog.scriptBytes()))) + .isTrue(); + loadedPrograms.add(sha1); + return sha1; + } + + @Override + public void close() {} + + private void requireLoaded(String sha1) { + if (!loadedPrograms.contains(sha1)) { + throw new RedisNoScriptException(); + } + } + + private void recordWrite(byte[] key, byte[] value, Duration ttl) { + values.put(text(key), value.clone()); + activeTtls.put(text(key), ttl); + timedWrites.add(new TimedWrite(text(key), ttl)); + } + + private void observePreseeded(List keys) { + for (byte[] key : keys) { + Duration ttl = activeTtls.get(text(key)); + if (ttl == null + || ttl.isZero() + || ttl.isNegative() + || ttl.compareTo(RedisSemanticProbePlan.MAXIMUM_TTL) > 0) { + everyProgramKeyPreseeded = false; + } + } + } + + private static String text(byte[] value) { + return new String(value, UTF_8); + } + + private static List asciiFields(String... fields) { + return Arrays.stream(fields).map(field -> field.getBytes(US_ASCII)).toList(); + } + } + + private record TimedWrite(String key, Duration ttl) {} +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClientTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClientTest.java new file mode 100644 index 0000000..f0057af --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelDiscoveryClientTest.java @@ -0,0 +1,689 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisCredentialsProvider; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisFuture; +import io.lettuce.core.RedisURI; +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.sentinel.api.StatefulRedisSentinelConnection; +import io.lettuce.core.sentinel.api.async.RedisSentinelAsyncCommands; +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class RedisSentinelDiscoveryClientTest { + + private static final RedisClientRuntimeSettings SETTINGS = + new RedisClientRuntimeSettings( + "sentinel-discovery", Duration.ofMillis(200), 17, 3, Duration.ofSeconds(5)); + + @Test + void returnsAQuorumApprovedUnresolvedMasterAndMapsEverySentinelToItsOwnDiscoveryUri() + throws Exception { + RedisDeploymentSettings.Sentinel deployment = deployment(); + RedisLettuceUris.SentinelDiscovery uris = discoveryUris(); + ClientOptions options = options(SETTINGS); + RecordingTransport transport = + new RecordingTransport( + List.of( + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + InetSocketAddress.createUnresolved("REDIS-PRIMARY.INTERNAL", 6379), + new TimeoutException("sentinel-c secret://redis/sentinel/password timed out"))); + + RedisSentinelMasterDiscovery.DataEndpoint discovered = + new RedisSentinelDiscoveryClient(deployment, uris, SETTINGS, options, transport).discover(); + + assertThat(discovered.host()).isEqualTo("redis-primary.internal"); + assertThat(discovered.port()).isEqualTo(6379); + assertThat(transport.uris).containsExactlyElementsOf(uris.discoveryUris()); + assertThat(transport.masterNames) + .containsExactly("cache-master", "cache-master", "cache-master"); + assertThat(transport.options).containsOnly(options); + assertThat(transport.settings).containsOnly(SETTINGS); + assertThat(transport.handles).allSatisfy(handle -> assertThat(handle.closed).isTrue()); + } + + @Test + void usesTheBoundedNoReplaySentinelOptionsForEveryIndependentDiscoveryConnection() { + ClientOptions options = options(SETTINGS); + RecordingTransport transport = + new RecordingTransport( + List.of( + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + InetSocketAddress.createUnresolved("redis-primary.internal", 6379))); + + new RedisSentinelDiscoveryClient(deployment(), discoveryUris(), SETTINGS, options, transport) + .discover(); + + assertThat(options.getDisconnectedBehavior()) + .isEqualTo(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS); + assertThat(options.getRequestQueueSize()).isEqualTo(17); + assertThat(options.getReplayFilter().test(null)).isTrue(); + assertThat(transport.options).containsOnly(options); + } + + @Test + void sanitizesMalformedSocketAddressesAndTransportFailures() { + RecordingTransport transport = + new RecordingTransport( + List.of( + new SocketAddress() {}, + new IllegalStateException( + "sentinel-b.internal cache-master secret://redis/sentinel/password raw reply"), + new SocketAddress() {})); + + assertThatThrownBy( + () -> + new RedisSentinelDiscoveryClient( + deployment(), discoveryUris(), SETTINGS, options(SETTINGS), transport) + .discover()) + .isInstanceOf(RedisSentinelMasterDiscovery.DiscoveryFailedException.class) + .hasMessage("Redis Sentinel master discovery failed") + .hasNoCause() + .hasMessageNotContaining("sentinel-b.internal") + .hasMessageNotContaining("cache-master") + .hasMessageNotContaining("secret://") + .hasMessageNotContaining("raw reply"); + assertThat(transport.handles).allSatisfy(handle -> assertThat(handle.closed).isTrue()); + } + + @Test + void rejectsASubstitutedDiscoveryUriBeforeOpeningAnyTransportConnection() { + RedisLettuceUris.SentinelDiscovery substituted = + new RedisLettuceUris.SentinelDiscovery( + List.of( + RedisURI.builder().withHost("unapproved.internal").withPort(26379).build(), + RedisURI.builder().withHost("sentinel-b.internal").withPort(26379).build(), + RedisURI.builder().withHost("sentinel-c.internal").withPort(26379).build())); + RecordingTransport transport = + new RecordingTransport( + List.of( + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + InetSocketAddress.createUnresolved("redis-primary.internal", 6379))); + + assertSanitizedFailure( + () -> + new RedisSentinelDiscoveryClient( + deployment(), substituted, SETTINGS, options(SETTINGS), transport) + .discover()); + + assertThat(transport.uris).isEmpty(); + } + + @Test + void interruptionAbortsTheWholeDiscoveryAttemptAndPreservesTheInterruptFlag() { + RecordingTransport transport = + new RecordingTransport( + List.of( + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + InetSocketAddress.createUnresolved("redis-primary.internal", 6379), + new InterruptedException( + "sentinel-user=discoverer secret=sentinel-secret raw=interrupted"))); + + try { + assertSanitizedFailure( + () -> + new RedisSentinelDiscoveryClient( + deployment(), discoveryUris(), SETTINGS, options(SETTINGS), transport) + .discover()); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } finally { + Thread.interrupted(); + } + } + + @Test + void productionTransportAppliesOptionsBoundsCommandsAndClosesWithoutDestroyingUriCredentials() + throws Exception { + DestroyableRedisCredentialsProvider credentials = + DestroyableRedisCredentialsProvider.from("sentinel-user", "sentinel-secret".toCharArray()); + RedisURI uri = + RedisURI.builder() + .withHost("sentinel-a.internal") + .withPort(26379) + .withAuthentication(credentials) + .build(); + FakeSentinelConnection connection = + new FakeSentinelConnection( + completed("PONG"), + completed(InetSocketAddress.createUnresolved("redis-primary.internal", 6379)), + completed(null)); + FakeRedisClient client = new FakeRedisClient(completed(connection.proxy()), completed(null)); + ClientOptions options = options(SETTINGS); + RedisSentinelDiscoveryClient.LettuceDiscoveryTransport transport = + new RedisSentinelDiscoveryClient.LettuceDiscoveryTransport(ignored -> client); + + try (RedisSentinelDiscoveryClient.DiscoveryHandle handle = + transport.connect(uri, options, SETTINGS)) { + assertThat(handle.getMasterAddrByName("cache-master")) + .isEqualTo(InetSocketAddress.createUnresolved("redis-primary.internal", 6379)); + } + + assertThat(client.options).isSameAs(options); + assertThat(client.connectedUri).isSameAs(uri); + assertThat(connection.commandTimeout).isEqualTo(SETTINGS.commandTimeout()); + assertThat(connection.pingCalls).hasValue(1); + assertThat(connection.masterNames).containsExactly("cache-master"); + assertThat(connection.asyncCloseCalls).hasValue(1); + assertThat(connection.syncCloseCalls).hasValue(0); + assertThat(client.asyncShutdownCalls).hasValue(1); + assertThat(client.syncShutdownCalls).hasValue(0); + assertThat(credentials.isDestroyed()).isFalse(); + } + + @Test + void productionTransportCancelsTimeoutAndStillShutsDownAfterPartialPingFailure() { + FakeSentinelConnection connection = + new FakeSentinelConnection( + incomplete(), + completed(InetSocketAddress.createUnresolved("redis-primary.internal", 6379)), + completed(null)); + FakeRedisClient client = new FakeRedisClient(completed(connection.proxy()), completed(null)); + RedisClientRuntimeSettings shortTimeouts = + new RedisClientRuntimeSettings( + "sentinel-short", Duration.ofMillis(1), 3, 1, Duration.ofSeconds(1)); + RedisSentinelDiscoveryClient.LettuceDiscoveryTransport transport = + new RedisSentinelDiscoveryClient.LettuceDiscoveryTransport(ignored -> client); + + assertThatThrownBy( + () -> + transport.connect( + RedisURI.builder().withHost("sentinel-a.internal").withPort(26379).build(), + options(shortTimeouts), + shortTimeouts)) + .isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining("sentinel-user") + .hasMessageNotContaining("sentinel-secret") + .hasMessageNotContaining("raw"); + assertThat(connection.pingFuture.isCancelled()).isTrue(); + assertThat(connection.asyncCloseCalls).hasValue(1); + assertThat(connection.syncCloseCalls).hasValue(0); + assertThat(client.asyncShutdownCalls).hasValue(1); + assertThat(client.syncShutdownCalls).hasValue(0); + } + + @Test + void productionTransportCancelsTimedOutMasterQueryBeforeItsBoundedAsyncCleanup() + throws Exception { + FakeSentinelConnection connection = + new FakeSentinelConnection(completed("PONG"), incomplete(), completed(null)); + FakeRedisClient client = new FakeRedisClient(completed(connection.proxy()), completed(null)); + RedisClientRuntimeSettings shortTimeouts = + new RedisClientRuntimeSettings( + "sentinel-query", Duration.ofMillis(1), 3, 1, Duration.ofSeconds(1)); + RedisSentinelDiscoveryClient.LettuceDiscoveryTransport transport = + new RedisSentinelDiscoveryClient.LettuceDiscoveryTransport(ignored -> client); + + try (RedisSentinelDiscoveryClient.DiscoveryHandle handle = + transport.connect( + RedisURI.builder().withHost("sentinel-a.internal").withPort(26379).build(), + options(shortTimeouts), + shortTimeouts)) { + assertThatThrownBy(() -> handle.getMasterAddrByName("cache-master")) + .isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining("cache-master"); + } + + assertThat(connection.masterFuture.isCancelled()).isTrue(); + assertThat(connection.asyncCloseCalls).hasValue(1); + assertThat(client.asyncShutdownCalls).hasValue(1); + } + + @Test + void connectionCloseAndClientShutdownShareOneMonotonicCleanupBudget() throws Exception { + AtomicLong nanoTime = new AtomicLong(); + Duration shutdownTimeout = Duration.ofSeconds(10); + BudgetConsumingFuture connectionClose = + BudgetConsumingFuture.completesAfter(nanoTime, Duration.ofSeconds(6)); + BudgetConsumingFuture clientShutdown = + BudgetConsumingFuture.completesAfter(nanoTime, Duration.ZERO); + FakeSentinelConnection connection = + new FakeSentinelConnection( + completed("PONG"), + completed(InetSocketAddress.createUnresolved("redis-primary.internal", 6379)), + connectionClose); + FakeRedisClient client = new FakeRedisClient(completed(connection.proxy()), clientShutdown); + RedisClientRuntimeSettings cleanupSettings = settingsWithShutdown(shutdownTimeout); + RedisSentinelDiscoveryClient.LettuceDiscoveryTransport transport = + new RedisSentinelDiscoveryClient.LettuceDiscoveryTransport( + ignored -> client, nanoTime::get); + + try (RedisSentinelDiscoveryClient.DiscoveryHandle ignored = + transport.connect( + RedisURI.builder().withHost("sentinel-a.internal").withPort(26379).build(), + options(cleanupSettings), + cleanupSettings)) {} + + assertThat(connectionClose.awaitTimeouts).containsExactly(shutdownTimeout); + assertThat(client.shutdownTimeouts).containsExactly(Duration.ofSeconds(4)); + assertThat(clientShutdown.awaitTimeouts).containsExactly(Duration.ofSeconds(4)); + assertThat(connection.asyncCloseCalls).hasValue(1); + assertThat(client.asyncShutdownCalls).hasValue(1); + } + + @Test + void interruptedConnectionCleanupStillAttemptsBoundedClientShutdownAndSanitizesTheInterrupt() { + AtomicLong nanoTime = new AtomicLong(); + Duration shutdownTimeout = Duration.ofSeconds(10); + BudgetConsumingFuture connectionClose = + BudgetConsumingFuture.interruptsAfter( + nanoTime, + Duration.ofSeconds(6), + "sentinel-a.internal secret://redis/sentinel/password cleanup interrupted"); + BudgetConsumingFuture clientShutdown = + BudgetConsumingFuture.completesAfter(nanoTime, Duration.ZERO); + FakeSentinelConnection connection = + new FakeSentinelConnection( + completed("PONG"), + completed(InetSocketAddress.createUnresolved("redis-primary.internal", 6379)), + connectionClose); + FakeRedisClient client = new FakeRedisClient(completed(connection.proxy()), clientShutdown); + RedisClientRuntimeSettings cleanupSettings = settingsWithShutdown(shutdownTimeout); + RedisSentinelDiscoveryClient.LettuceDiscoveryTransport transport = + new RedisSentinelDiscoveryClient.LettuceDiscoveryTransport( + ignored -> client, nanoTime::get); + + try { + assertThatThrownBy( + () -> { + RedisSentinelDiscoveryClient.DiscoveryHandle handle = + transport.connect( + RedisURI.builder().withHost("sentinel-a.internal").withPort(26379).build(), + options(cleanupSettings), + cleanupSettings); + handle.close(); + }) + .isInstanceOf(InterruptedException.class) + .hasMessage("Redis Sentinel discovery close interrupted") + .hasNoCause() + .hasMessageNotContaining("sentinel-a.internal") + .hasMessageNotContaining("secret://"); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } finally { + Thread.interrupted(); + } + + assertThat(connectionClose.awaitTimeouts).containsExactly(shutdownTimeout); + assertThat(client.shutdownTimeouts).containsExactly(Duration.ofSeconds(4)); + assertThat(clientShutdown.awaitTimeouts).containsExactly(Duration.ofSeconds(4)); + assertThat(connection.asyncCloseCalls).hasValue(1); + assertThat(client.asyncShutdownCalls).hasValue(1); + } + + @Test + void sanitizesBoundedCloseFailuresAndStillShutsDownEveryCreatedClient() { + DestroyableRedisCredentialsProvider credentials = + DestroyableRedisCredentialsProvider.from("sentinel-user", "sentinel-secret".toCharArray()); + List clients = + List.of( + clientWithCloseFailure( + "close username=sentinel-user secret=sentinel-secret raw=close-a"), + clientWithCloseFailure( + "close username=sentinel-user secret=sentinel-secret raw=close-b"), + clientWithCloseFailure( + "close username=sentinel-user secret=sentinel-secret raw=close-c")); + AtomicInteger next = new AtomicInteger(); + RedisSentinelDiscoveryClient.LettuceDiscoveryTransport transport = + new RedisSentinelDiscoveryClient.LettuceDiscoveryTransport( + ignored -> clients.get(next.getAndIncrement())); + RedisLettuceUris.SentinelDiscovery uris = credentialBearingDiscoveryUris(credentials); + + assertSanitizedFailure( + () -> + new RedisSentinelDiscoveryClient( + deployment(), uris, SETTINGS, options(SETTINGS), transport) + .discover()); + + assertThat(clients).allSatisfy(client -> assertThat(client.asyncShutdownCalls).hasValue(1)); + assertThat(clients).allSatisfy(client -> assertThat(client.syncShutdownCalls).hasValue(0)); + assertThat(credentials.isDestroyed()).isFalse(); + } + + private static RedisDeploymentSettings.Sentinel deployment() { + return new RedisDeploymentSettings.Sentinel( + "cache-sentinel", + 0, + "cache-master", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication("sentinel", "secret://redis/sentinel/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/sentinel/ca"), + new RedisDeploymentSettings.Authentication("data", "secret://redis/data/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/data/ca")); + } + + private static ClientOptions options(RedisClientRuntimeSettings settings) { + return ClientOptions.builder() + .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) + .requestQueueSize(settings.maximumQueuedCommands()) + .replayFilter(ignored -> true) + .build(); + } + + private static RedisClientRuntimeSettings settingsWithShutdown(Duration shutdownTimeout) { + return new RedisClientRuntimeSettings( + "sentinel-cleanup", + Duration.ofSeconds(1), + Duration.ofSeconds(1), + Duration.ofSeconds(1), + Duration.ofSeconds(5), + shutdownTimeout, + 17, + 3, + Duration.ofSeconds(5)); + } + + private static RedisLettuceUris.SentinelDiscovery discoveryUris() { + return new RedisLettuceUris.SentinelDiscovery( + List.of( + RedisURI.builder().withHost("sentinel-a.internal").withPort(26379).build(), + RedisURI.builder().withHost("sentinel-b.internal").withPort(26379).build(), + RedisURI.builder().withHost("sentinel-c.internal").withPort(26379).build())); + } + + private static final class RecordingTransport + implements RedisSentinelDiscoveryClient.DiscoveryTransport { + + private final List replies; + private final List uris = new ArrayList<>(); + private final List masterNames = new ArrayList<>(); + private final List options = new ArrayList<>(); + private final List settings = new ArrayList<>(); + private final List handles = new ArrayList<>(); + private int index; + + private RecordingTransport(List replies) { + this.replies = replies; + } + + @Override + public RedisSentinelDiscoveryClient.DiscoveryHandle connect( + RedisURI uri, ClientOptions clientOptions, RedisClientRuntimeSettings clientSettings) { + uris.add(uri); + options.add(clientOptions); + settings.add(clientSettings); + Handle handle = new Handle(replies.get(index++), masterNames); + handles.add(handle); + return handle; + } + } + + private static final class Handle implements RedisSentinelDiscoveryClient.DiscoveryHandle { + + private final Object reply; + private final List masterNames; + private boolean closed; + + private Handle(Object reply, List masterNames) { + this.reply = reply; + this.masterNames = masterNames; + } + + @Override + public SocketAddress getMasterAddrByName(String masterName) throws Exception { + masterNames.add(masterName); + if (reply instanceof Exception exception) { + throw exception; + } + return (SocketAddress) reply; + } + + @Override + public void close() { + closed = true; + } + } + + private static RedisLettuceUris.SentinelDiscovery credentialBearingDiscoveryUris( + DestroyableRedisCredentialsProvider credentials) { + return new RedisLettuceUris.SentinelDiscovery( + List.of( + RedisURI.builder() + .withHost("sentinel-a.internal") + .withPort(26379) + .withAuthentication(credentials) + .build(), + RedisURI.builder() + .withHost("sentinel-b.internal") + .withPort(26379) + .withAuthentication(credentials) + .build(), + RedisURI.builder() + .withHost("sentinel-c.internal") + .withPort(26379) + .withAuthentication(credentials) + .build())); + } + + private static FakeRedisClient clientWithCloseFailure(String rawFailure) { + FakeSentinelConnection connection = + new FakeSentinelConnection( + completed("PONG"), + completed(InetSocketAddress.createUnresolved("redis-primary.internal", 6379)), + failed(new IllegalStateException(rawFailure))); + return new FakeRedisClient(completed(connection.proxy()), completed(null)); + } + + private static FakeRedisFuture completed(T value) { + FakeRedisFuture future = new FakeRedisFuture<>(); + future.complete(value); + return future; + } + + private static FakeRedisFuture failed(Throwable failure) { + FakeRedisFuture future = new FakeRedisFuture<>(); + future.completeExceptionally(failure); + return future; + } + + private static FakeRedisFuture incomplete() { + return new FakeRedisFuture<>(); + } + + private static void assertSanitizedFailure(ThrowingOperation operation) { + assertThatThrownBy(operation::run) + .isInstanceOf(RedisSentinelMasterDiscovery.DiscoveryFailedException.class) + .hasMessage("Redis Sentinel master discovery failed") + .hasNoCause() + .hasMessageNotContaining("sentinel-a.internal") + .hasMessageNotContaining("cache-master") + .hasMessageNotContaining("sentinel-user") + .hasMessageNotContaining("sentinel-secret") + .hasMessageNotContaining("secret://") + .hasMessageNotContaining("raw"); + } + + @FunctionalInterface + private interface ThrowingOperation { + + void run() throws Exception; + } + + private static final class FakeRedisFuture extends CompletableFuture + implements RedisFuture { + + @Override + public String getError() { + return null; + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + try { + get(timeout, unit); + return true; + } catch (TimeoutException exception) { + return false; + } catch (java.util.concurrent.ExecutionException exception) { + return true; + } + } + } + + private static final class FakeRedisClient extends RedisClient { + + private final CompletableFuture> connectFuture; + private final CompletableFuture shutdownFuture; + private final AtomicInteger asyncShutdownCalls = new AtomicInteger(); + private final AtomicInteger syncShutdownCalls = new AtomicInteger(); + private final List shutdownTimeouts = new ArrayList<>(); + private ClientOptions options; + private RedisURI connectedUri; + + private FakeRedisClient( + CompletableFuture> connectFuture, + CompletableFuture shutdownFuture) { + this.connectFuture = connectFuture; + this.shutdownFuture = shutdownFuture; + } + + @Override + public void setOptions(ClientOptions clientOptions) { + options = clientOptions; + } + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture> connectSentinelAsync( + RedisCodec codec, RedisURI uri) { + connectedUri = uri; + return (CompletableFuture>) + (CompletableFuture) connectFuture; + } + + @Override + public CompletableFuture shutdownAsync(long quietPeriod, long timeout, TimeUnit unit) { + asyncShutdownCalls.incrementAndGet(); + shutdownTimeouts.add(Duration.ofNanos(unit.toNanos(timeout))); + return shutdownFuture; + } + + @Override + public void shutdown(Duration quietPeriod, Duration timeout) { + syncShutdownCalls.incrementAndGet(); + } + } + + private static final class BudgetConsumingFuture extends CompletableFuture { + + private final AtomicLong nanoTime; + private final long consumedNanos; + private final String interruptionMessage; + private final List awaitTimeouts = new ArrayList<>(); + + private BudgetConsumingFuture( + AtomicLong nanoTime, Duration consumed, String interruptionMessage) { + this.nanoTime = nanoTime; + this.consumedNanos = consumed.toNanos(); + this.interruptionMessage = interruptionMessage; + complete(null); + } + + private static BudgetConsumingFuture completesAfter(AtomicLong nanoTime, Duration consumed) { + return new BudgetConsumingFuture(nanoTime, consumed, null); + } + + private static BudgetConsumingFuture interruptsAfter( + AtomicLong nanoTime, Duration consumed, String message) { + return new BudgetConsumingFuture(nanoTime, consumed, message); + } + + @Override + public Void get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + awaitTimeouts.add(Duration.ofNanos(unit.toNanos(timeout))); + nanoTime.addAndGet(consumedNanos); + if (interruptionMessage != null) { + throw new InterruptedException(interruptionMessage); + } + return super.get(timeout, unit); + } + } + + private static final class FakeSentinelConnection { + + private final FakeRedisFuture pingFuture; + private final FakeRedisFuture masterFuture; + private final CompletableFuture closeFuture; + private final AtomicInteger pingCalls = new AtomicInteger(); + private final AtomicInteger asyncCloseCalls = new AtomicInteger(); + private final AtomicInteger syncCloseCalls = new AtomicInteger(); + private final List masterNames = new ArrayList<>(); + private Duration commandTimeout; + + private FakeSentinelConnection( + FakeRedisFuture pingFuture, + FakeRedisFuture masterFuture, + CompletableFuture closeFuture) { + this.pingFuture = pingFuture; + this.masterFuture = masterFuture; + this.closeFuture = closeFuture; + } + + @SuppressWarnings("unchecked") + private StatefulRedisSentinelConnection proxy() { + RedisSentinelAsyncCommands commands = + (RedisSentinelAsyncCommands) + Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] {RedisSentinelAsyncCommands.class}, + (proxy, method, args) -> { + if (method.getName().equals("ping")) { + pingCalls.incrementAndGet(); + return pingFuture; + } + if (method.getName().equals("getMasterAddrByName")) { + masterNames.add((String) args[0]); + return masterFuture; + } + throw new UnsupportedOperationException(method.getName()); + }); + return (StatefulRedisSentinelConnection) + Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] {StatefulRedisSentinelConnection.class}, + (proxy, method, args) -> { + if (method.getName().equals("async")) { + return commands; + } + if (method.getName().equals("setTimeout")) { + commandTimeout = (Duration) args[0]; + return null; + } + if (method.getName().equals("closeAsync")) { + asyncCloseCalls.incrementAndGet(); + return closeFuture; + } + if (method.getName().equals("close")) { + syncCloseCalls.incrementAndGet(); + return null; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinatorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinatorTest.java new file mode 100644 index 0000000..6f6ed80 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelFailoverCoordinatorTest.java @@ -0,0 +1,747 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RedisSentinelFailoverCoordinatorTest { + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void oneWorkerOwnsExactlyOneRecurringTaskPerActiveSentinelRole(int activeRoleCount) { + DeterministicWorker worker = new DeterministicWorker(); + Map deployments = new EnumMap<>(RedisRole.class); + RedisRole[] roles = RedisRole.values(); + for (int index = 0; index < activeRoleCount; index++) { + deployments.put( + roles[index], sentinel(roles[index].name().toLowerCase(java.util.Locale.ROOT))); + } + Map routers = new EnumMap<>(RedisRole.class); + deployments.keySet().forEach(role -> routers.put(role, router(runtime(role.name(), "a")))); + + try (RedisSentinelFailoverCoordinator coordinator = + coordinator(deployments, routers, new RecordingConnector(), worker)) { + assertThat(worker.factoryCalls).hasValue(1); + assertThat(worker.recurringTasks).hasSize(activeRoleCount); + assertThat(worker.periods).allMatch(Duration.ofSeconds(30)::equals); + } finally { + routers.values().forEach(RedisRoleCommandRouter::close); + } + } + + @Test + void thirtyTwoImmediateRequestsCoalesceIntoOneDiscovery() throws Exception { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + RedisRoleCommandRouter.RouteToken failed = router.routeToken(); + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + CountDownLatch ready = new CountDownLatch(32); + CountDownLatch go = new CountDownLatch(1); + Thread[] threads = new Thread[32]; + for (int index = 0; index < threads.length; index++) { + threads[index] = + Thread.ofPlatform() + .start( + () -> { + ready.countDown(); + await(go); + coordinator.requestRecovery(RedisRole.COORDINATION, failed); + }); + } + assertThat(ready.await(2, TimeUnit.SECONDS)).isTrue(); + go.countDown(); + for (Thread thread : threads) { + thread.join(); + } + + assertThat(worker.immediateTasks).hasSize(1); + worker.runNextImmediate(); + assertThat(connector.discoveries).hasValue(1); + } finally { + router.close(); + } + } + + @Test + void unchangedPrimaryDiscoversWithoutDataConnectOrInstall() { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoutableCommandRuntime initial = runtime("coord", "a"); + RedisRoleCommandRouter router = router(initial); + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + connector.discovered.set(route("a")); + + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(connector.discoveries).hasValue(1); + assertThat(connector.connects).hasValue(0); + assertThat(router.routeToken().generation()).isZero(); + } finally { + router.close(); + } + } + + @Test + void changedPrimaryConnectsExactCandidateAndInstallsConditionallyOnce() { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + TrackingRuntime candidate = runtime("coord", "b"); + connector.discovered.set(route("b")); + connector.candidate.set(candidate); + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(connector.discoveries).hasValue(1); + assertThat(connector.connects).hasValue(1); + assertThat(connector.connectedRoute.get()).isSameAs(connector.discovered.get()); + assertThat(candidate.probes).hasValue(1); + assertThat(router.routeToken().generation()).isEqualTo(1); + assertThat(router.routeToken().identity()).isEqualTo(route("b").identity()); + } finally { + router.close(); + } + } + + @Test + void staleFailedRouteTokenPerformsZeroDiscovery() { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + RedisRoleCommandRouter.RouteToken stale = router.routeToken(); + router.swap(runtime("coord", "b"), Duration.ofMillis(50), Duration.ofMillis(100)); + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + coordinator.requestRecovery(RedisRole.COORDINATION, stale); + + assertThat(worker.immediateTasks).isEmpty(); + assertThat(connector.discoveries).hasValue(0); + } finally { + router.close(); + } + } + + @Test + void routeChangingDuringDiscoverySkipsDataConnectAndKeepsManualRoute() { + DeterministicWorker worker = new DeterministicWorker(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + RedisRoutableCommandRuntime manual = runtime("coord", "c"); + RecordingConnector connector = + new RecordingConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + discoveries.incrementAndGet(); + router.swap(manual, Duration.ofMillis(50), Duration.ofMillis(100)); + return route("b"); + } + }; + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(connector.connects).hasValue(0); + assertThat(router.routeToken().identity()).isEqualTo(route("c").identity()); + } finally { + router.close(); + } + } + + @Test + void aFailedRefreshIsContainedAndFuturePollingContinues() { + DeterministicWorker worker = new DeterministicWorker(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + RecordingConnector connector = + new RecordingConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + if (discoveries.incrementAndGet() == 1) { + throw new IllegalStateException( + "provider endpoint=secret.internal password=do-not-leak"); + } + return route("a"); + } + }; + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + worker.runRecurring(0); + worker.runNextImmediate(); + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(connector.discoveries).hasValue(2); + assertThat(worker.uncaughtFailures).isEmpty(); + } finally { + router.close(); + } + } + + @Test + void scheduledAndImmediateRequestsShareOneQueuedRefreshAndOneFollowUpWhileRunning() { + DeterministicWorker worker = new DeterministicWorker(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + AtomicReference coordinatorReference = + new AtomicReference<>(); + RecordingConnector connector = + new RecordingConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + int call = discoveries.incrementAndGet(); + if (call == 1) { + coordinatorReference + .get() + .requestRecovery(RedisRole.COORDINATION, router.routeToken()); + coordinatorReference + .get() + .requestRecovery(RedisRole.COORDINATION, router.routeToken()); + } + return route("a"); + } + }; + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + coordinatorReference.set(coordinator); + worker.runRecurring(0); + coordinator.requestRecovery(RedisRole.COORDINATION, router.routeToken()); + assertThat(worker.immediateTasks).hasSize(1); + + worker.runNextImmediate(); + assertThat(worker.immediateTasks).hasSize(1); + worker.runNextImmediate(); + + assertThat(connector.discoveries).hasValue(2); + assertThat(worker.immediateTasks).isEmpty(); + } finally { + router.close(); + } + } + + @Test + void commandFailureFollowUpRetainsItsTokenAndSkipsDiscoveryAfterManualRotation() { + DeterministicWorker worker = new DeterministicWorker(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + AtomicReference coordinatorReference = + new AtomicReference<>(); + RecordingConnector connector = + new RecordingConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + discoveries.incrementAndGet(); + coordinatorReference.get().requestRecovery(RedisRole.COORDINATION, router.routeToken()); + return route("a"); + } + }; + try (RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker)) { + coordinatorReference.set(coordinator); + worker.runRecurring(0); + worker.runNextImmediate(); + router.swap(runtime("coord", "c"), Duration.ofMillis(50), Duration.ofMillis(100)); + + worker.runNextImmediate(); + + assertThat(connector.discoveries).hasValue(1); + assertThat(router.routeToken().identity()).isEqualTo(route("c").identity()); + } finally { + router.close(); + } + } + + @Test + void closeDuringBlockedConnectPreventsInstallAndClosesLateCandidateOnce() throws Exception { + DeterministicWorker worker = new DeterministicWorker(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + TrackingRuntime lateCandidate = runtime("coord", "b"); + CountDownLatch connectStarted = new CountDownLatch(1); + CountDownLatch releaseConnect = new CountDownLatch(1); + RecordingConnector connector = + new RecordingConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + discoveries.incrementAndGet(); + return route("b"); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + connects.incrementAndGet(); + connectStarted.countDown(); + await(releaseConnect); + return lateCandidate; + } + }; + RedisSentinelFailoverCoordinator coordinator = + coordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + worker); + worker.runRecurring(0); + Thread refresh = Thread.ofPlatform().start(worker::runNextImmediate); + assertThat(connectStarted.await(2, TimeUnit.SECONDS)).isTrue(); + + coordinator.close(); + releaseConnect.countDown(); + refresh.join(2_000); + + assertThat(refresh.isAlive()).isFalse(); + assertThat(lateCandidate.closes).hasValue(1); + assertThat(router.routeToken().identity()).isEqualTo(route("a").identity()); + assertThat(worker.closed).isTrue(); + assertThat(worker.recurringTasks).isEmpty(); + router.close(); + } + + @Test + void closeDuringBlockedQualificationPreventsInstallAndClosesQualifiedCandidateOnce() + throws Exception { + DeterministicWorker worker = new DeterministicWorker(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + TrackingRuntime candidate = runtime("coord", "b"); + RecordingConnector connector = new RecordingConnector(); + connector.discovered.set(route("b")); + connector.candidate.set(candidate); + CountDownLatch qualificationStarted = new CountDownLatch(1); + CountDownLatch releaseQualification = new CountDownLatch(1); + RedisSentinelFailoverCoordinator coordinator = + new RedisSentinelFailoverCoordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + (role, connected) -> { + qualificationStarted.countDown(); + await(releaseQualification); + return RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED; + }, + (role, result) -> {}, + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofSeconds(30), + Duration.ofSeconds(1), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + }); + worker.runRecurring(0); + Thread refresh = Thread.ofPlatform().start(worker::runNextImmediate); + assertThat(qualificationStarted.await(2, TimeUnit.SECONDS)).isTrue(); + + coordinator.close(); + releaseQualification.countDown(); + refresh.join(2_000); + + assertThat(refresh.isAlive()).isFalse(); + assertThat(candidate.closes).hasValue(1); + assertThat(router.routeToken().identity()).isEqualTo(route("a").identity()); + router.close(); + } + + @Test + void realWorkerCloseInterruptsCooperativeBlockedDiscoveryAndTerminates() throws Exception { + RedisRoleCommandRouter router = router(runtime("coord", "a")); + CountDownLatch discoveryStarted = new CountDownLatch(1); + CountDownLatch discoveryInterrupted = new CountDownLatch(1); + AtomicReference discoveryThread = new AtomicReference<>(); + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + discoveryThread.set(Thread.currentThread()); + discoveryStarted.countDown(); + try { + new CountDownLatch(1).await(); + throw new AssertionError("blocked discovery unexpectedly resumed"); + } catch (InterruptedException expected) { + discoveryInterrupted.countDown(); + Thread.currentThread().interrupt(); + throw new RedisTemporaryConnectionException(); + } + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + throw new AssertionError("interrupted discovery must not open a data candidate"); + } + }; + RedisSentinelFailoverCoordinator coordinator = + new RedisSentinelFailoverCoordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + (role, candidate) -> RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED, + (role, result) -> {}, + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofMinutes(5), + Duration.ofSeconds(1), + BoundedRedisSentinelRefreshWorker::new); + coordinator.requestRecovery(RedisRole.COORDINATION, router.routeToken()); + assertThat(discoveryStarted.await(2, TimeUnit.SECONDS)).isTrue(); + + coordinator.close(); + + assertThat(discoveryInterrupted.await(1, TimeUnit.SECONDS)).isTrue(); + discoveryThread.get().join(1_000); + assertThat(discoveryThread.get().isAlive()).isFalse(); + assertThat(router.routeToken().generation()).isZero(); + router.close(); + } + + @Test + void qualifierFailureBeforeInstallClosesUnusedCandidateExactlyOnce() { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + TrackingRuntime candidate = runtime("coord", "b"); + connector.discovered.set(route("b")); + connector.candidate.set(candidate); + try (RedisSentinelFailoverCoordinator coordinator = + new RedisSentinelFailoverCoordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + (role, unused) -> { + throw new IllegalStateException("provider secret=do-not-leak"); + }, + (role, result) -> {}, + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofSeconds(30), + Duration.ofSeconds(1), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + })) { + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(candidate.closes).hasValue(1); + assertThat(router.routeToken().identity()).isEqualTo(route("a").identity()); + assertThat(worker.uncaughtFailures).isEmpty(); + } finally { + router.close(); + } + } + + @Test + void nullQualificationClosesUnusedCandidateExactlyOnce() { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + TrackingRuntime candidate = runtime("coord", "b"); + connector.discovered.set(route("b")); + connector.candidate.set(candidate); + try (RedisSentinelFailoverCoordinator coordinator = + new RedisSentinelFailoverCoordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + (role, unused) -> null, + (role, result) -> {}, + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofSeconds(30), + Duration.ofSeconds(1), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + })) { + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(candidate.closes).hasValue(1); + assertThat(router.routeToken().identity()).isEqualTo(route("a").identity()); + } finally { + router.close(); + } + } + + @Test + void installationObserverFailureDoesNotCloseTheNewActiveRuntime() { + DeterministicWorker worker = new DeterministicWorker(); + RecordingConnector connector = new RecordingConnector(); + RedisRoleCommandRouter router = router(runtime("coord", "a")); + TrackingRuntime candidate = runtime("coord", "b"); + connector.discovered.set(route("b")); + connector.candidate.set(candidate); + try (RedisSentinelFailoverCoordinator coordinator = + new RedisSentinelFailoverCoordinator( + Map.of(RedisRole.COORDINATION, sentinel("coord")), + Map.of(RedisRole.COORDINATION, router), + connector, + (role, installed) -> RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED, + (role, result) -> { + throw new IllegalStateException("provider secret=do-not-leak"); + }, + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofSeconds(30), + Duration.ofSeconds(1), + (capacity, threadName) -> { + worker.capacity = capacity; + return worker; + })) { + worker.runRecurring(0); + worker.runNextImmediate(); + + assertThat(candidate.closes).hasValue(0); + assertThat(router.routeToken().identity()).isEqualTo(route("b").identity()); + assertThat(worker.uncaughtFailures).isEmpty(); + } finally { + router.close(); + } + } + + private static RedisSentinelFailoverCoordinator coordinator( + Map deployments, + Map routers, + RedisSentinelRuntimeConnector connector, + DeterministicWorker worker) { + return new RedisSentinelFailoverCoordinator( + deployments, + routers, + connector, + (role, candidate) -> RedisSentinelFailoverCoordinator.CandidateQualification.ACCEPTED, + (role, result) -> {}, + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofSeconds(30), + Duration.ofSeconds(1), + (capacity, threadName) -> { + worker.factoryCalls.incrementAndGet(); + worker.capacity = capacity; + return worker; + }); + } + + private static RedisRoleCommandRouter router(RedisRoutableCommandRuntime runtime) { + return new RedisRoleCommandRouter( + RedisRole.COORDINATION, + runtime, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5)); + } + + private static TrackingRuntime runtime(String deployment, String endpoint) { + return new TrackingRuntime(deployment, route(endpoint).identity()); + } + + private static RedisSentinelDiscoveredRoute route(String endpoint) { + return RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint(endpoint + ".internal", 6379)); + } + + private static RedisDeploymentSettings.Sentinel sentinel(String id) { + return new RedisDeploymentSettings.Sentinel( + id, + 0, + "master", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("a.internal", 6379), + new RedisDeploymentSettings.Endpoint("b.internal", 6379), + new RedisDeploymentSettings.Endpoint("c.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "sentinel", "secret://environment/SENTINEL_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/SENTINEL_TRUST_PEM"), + new RedisDeploymentSettings.Authentication("data", "secret://environment/REDIS_PASSWORD"), + new RedisDeploymentSettings.Tls(true, true, "secret://environment/REDIS_TRUST_PEM")); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("test wait interrupted", interrupted); + } + } + + private static class RecordingConnector implements RedisSentinelRuntimeConnector { + + final AtomicInteger discoveries = new AtomicInteger(); + final AtomicInteger connects = new AtomicInteger(); + final AtomicReference discovered = + new AtomicReference<>(route("a")); + final AtomicReference candidate = new AtomicReference<>(runtime("coord", "b")); + final AtomicReference connectedRoute = new AtomicReference<>(); + + @Override + public RedisSentinelDiscoveredRoute discover(RedisDeploymentSettings.Sentinel deployment) { + discoveries.incrementAndGet(); + return discovered.get(); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, RedisSentinelDiscoveredRoute discoveredRoute) { + connects.incrementAndGet(); + connectedRoute.set(discoveredRoute); + return candidate.get(); + } + } + + private static final class TrackingRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final RedisRouteIdentity identity; + private final AtomicInteger probes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + private TrackingRuntime(String deploymentId, RedisRouteIdentity identity) { + this.deploymentId = deploymentId; + this.identity = identity; + } + + @Override + public RedisRouteIdentity routeIdentity() { + return identity; + } + + @Override + public void probe(Duration timeout) { + probes.incrementAndGet(); + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return deploymentId.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + private static final class DeterministicWorker implements RedisSentinelRefreshWorker { + + private final AtomicInteger factoryCalls = new AtomicInteger(); + private final Queue immediateTasks = new ArrayDeque<>(); + private final List recurringTasks = new java.util.ArrayList<>(); + private final List periods = new java.util.ArrayList<>(); + private final List uncaughtFailures = new java.util.ArrayList<>(); + private int capacity; + private boolean closed; + + @Override + public Cancellable scheduleWithFixedDelay(Runnable task, Duration delay) { + recurringTasks.add(task); + periods.add(delay); + return () -> recurringTasks.remove(task); + } + + @Override + public boolean execute(Runnable task) { + if (closed || immediateTasks.size() >= capacity) { + return false; + } + immediateTasks.add(task); + return true; + } + + @Override + public void shutdown(Duration timeout) { + closed = true; + immediateTasks.clear(); + recurringTasks.clear(); + } + + private void runRecurring(int index) { + runContained(recurringTasks.get(index)); + } + + private void runNextImmediate() { + runContained(immediateTasks.remove()); + } + + private void runContained(Runnable task) { + try { + task.run(); + } catch (RuntimeException failure) { + uncaughtFailures.add(failure); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java new file mode 100644 index 0000000..9717967 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelMasterDiscoveryTest.java @@ -0,0 +1,267 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class RedisSentinelMasterDiscoveryTest { + + private static final List SENTINELS = + List.of( + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-a.internal", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-b.internal", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-c.internal", 26379)); + + private static final Set EXPECTED_DATA_ENDPOINTS = + Set.of( + new RedisSentinelMasterDiscovery.DataEndpoint("MASTER-A.INTERNAL", 6379), + new RedisSentinelMasterDiscovery.DataEndpoint("master-b.internal", 6380)); + + @Test + void returnsTheNormalizedAllowlistedMasterWhenTwoSentinelsAgreeAndOneTimesOut() + throws Exception { + List queried = new ArrayList<>(); + + RedisSentinelMasterDiscovery.DataEndpoint discovered = + RedisSentinelMasterDiscovery.discover( + SENTINELS, + "cache-master", + EXPECTED_DATA_ENDPOINTS, + (sentinel, masterName) -> { + queried.add(sentinel); + assertThat(masterName).isEqualTo("cache-master"); + if (sentinel.host().equals("sentinel-c.internal")) { + throw new TimeoutException("sentinel-c timeout with secret://redis/sentinel-password"); + } + return new RedisSentinelMasterDiscovery.MasterObservation("Master-A.Internal", "6379"); + }); + + assertThat(discovered) + .isEqualTo(new RedisSentinelMasterDiscovery.DataEndpoint("master-a.internal", 6379)); + assertThat(queried).containsExactlyElementsOf(SENTINELS); + } + + @ParameterizedTest + @MethodSource("nonQuorumObservations") + void failsClosedWhenTheObservationsDoNotProduceATwoSentinelQuorum( + List observations) { + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + SENTINELS, + "cache-master", + EXPECTED_DATA_ENDPOINTS, + new FixedObservations(observations))); + } + + private static List> nonQuorumObservations() { + return List.of( + List.of( + observation("master-a.internal", "6379"), + observation("master-b.internal", "6380"), + observation("master-c.internal", "6379")), + Arrays.asList(observation("master-a.internal", "6379"), null, null), + Arrays.asList( + observation("master-a.internal", "6379"), + observation("master-b.internal", "6380"), + null)); + } + + @ParameterizedTest + @MethodSource("rejectedObservations") + void rejectsMalformedLoopbackWildcardUnspecifiedAndUnexpectedMasters( + RedisSentinelMasterDiscovery.MasterObservation rejected) { + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + SENTINELS, + "cache-master", + EXPECTED_DATA_ENDPOINTS, + new FixedObservations(Arrays.asList(rejected, rejected, rejected)))); + } + + private static List rejectedObservations() { + return Arrays.asList( + null, + observation("", "6379"), + observation("master-a.internal", ""), + observation("master-a.internal", "not-a-number"), + observation("master-a.internal", "0"), + observation("master-a.internal", "65536"), + observation("127.0.0.1", "6379"), + observation("localhost", "6379"), + observation("0.0.0.0", "6379"), + observation("::1", "6379"), + observation("::", "6379"), + observation("a..b", "6379"), + observation("a.-b", "6379"), + observation("a:b", "6379"), + observation("999.1.1.1", "6379"), + observation("127.0.0.2", "6379"), + observation("0:0:0:0:0:0:0:1", "6379"), + observation("0:0:0:0:0:0:0:0", "6379"), + observation("::ffff:127.0.0.1", "6379"), + observation("unexpected.internal", "6379")); + } + + @ParameterizedTest + @MethodSource("syntacticallyInvalidMasterHosts") + void rejectsMalformedMasterHostsEvenWhenTheyAppearInTheConfiguredAllowlist(String host) { + Set invalidAllowlist = + Set.of(new RedisSentinelMasterDiscovery.DataEndpoint(host, 6379)); + + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + SENTINELS, + "cache-master", + invalidAllowlist, + new FixedObservations( + List.of(observation(host, "6379"), observation(host, "6379"), observation(host, "6379"))))); + } + + private static List syntacticallyInvalidMasterHosts() { + return List.of( + "localhost", "a..b", "a.-b", "a:b", "1.2.3", "256.0.0.1", "::ffff:127.0.0.1"); + } + + @Test + void rejectsDuplicateNormalizedSentinelsBeforeTheyCanManufactureAQuorum() { + List queried = new ArrayList<>(); + List duplicateSentinels = + List.of( + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-a.internal", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("SENTINEL-A.INTERNAL", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-b.internal", 26379)); + + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + duplicateSentinels, + "cache-master", + EXPECTED_DATA_ENDPOINTS, + (sentinel, masterName) -> { + queried.add(sentinel); + return observation("master-a.internal", "6379"); + })); + + assertThat(queried).isEmpty(); + } + + @ParameterizedTest + @MethodSource("invalidConfiguredSentinels") + void rejectsInvalidConfiguredSentinelsBeforeQuerying( + RedisSentinelMasterDiscovery.SentinelEndpoint invalidSentinel) { + List queried = new ArrayList<>(); + List configuredSentinels = + List.of(invalidSentinel, SENTINELS.get(1), SENTINELS.get(2)); + + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + configuredSentinels, + "cache-master", + EXPECTED_DATA_ENDPOINTS, + (sentinel, masterName) -> { + queried.add(sentinel); + return observation("master-a.internal", "6379"); + })); + + assertThat(queried).isEmpty(); + } + + private static List invalidConfiguredSentinels() { + return List.of( + new RedisSentinelMasterDiscovery.SentinelEndpoint("", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("a..b", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("a.-b", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("a:b", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("999.1.1.1", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("127.0.0.2", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("localhost", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("::0.0.0.0", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("::ffff:127.0.0.1", 26379), + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-a.internal", 0), + new RedisSentinelMasterDiscovery.SentinelEndpoint("sentinel-a.internal", 65536)); + } + + @Test + void requiresExactlyThreeSentinelsAndNeverQueriesAnInvalidAttempt() { + List queried = new ArrayList<>(); + + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + SENTINELS.subList(0, 2), + "cache-master", + EXPECTED_DATA_ENDPOINTS, + (sentinel, masterName) -> { + queried.add(sentinel); + return observation("master-a.internal", "6379"); + })); + + assertThat(queried).isEmpty(); + } + + @Test + void sanitizesFailuresFromTheSentinelQuery() { + assertSanitizedFailure( + () -> + RedisSentinelMasterDiscovery.discover( + SENTINELS, + "cache-master-secret-name", + EXPECTED_DATA_ENDPOINTS, + (sentinel, masterName) -> { + throw new IllegalStateException( + "sentinel-a.internal secret://redis/sentinel/password raw-reply"); + })); + } + + private static RedisSentinelMasterDiscovery.MasterObservation observation(String host, String port) { + return new RedisSentinelMasterDiscovery.MasterObservation(host, port); + } + + private static void assertSanitizedFailure(ThrowingOperation operation) { + assertThatThrownBy(operation::run) + .isInstanceOf(RedisSentinelMasterDiscovery.DiscoveryFailedException.class) + .hasMessage("Redis Sentinel master discovery failed") + .hasNoCause() + .hasMessageNotContaining("sentinel-a.internal") + .hasMessageNotContaining("cache-master") + .hasMessageNotContaining("secret://") + .hasMessageNotContaining("raw-reply") + .hasMessageNotContaining("not-a-number") + .hasMessageNotContaining("unexpected.internal"); + } + + @FunctionalInterface + private interface ThrowingOperation { + + void run() throws Exception; + } + + private static final class FixedObservations + implements RedisSentinelMasterDiscovery.SentinelQuery { + + private final List observations; + private int index; + + private FixedObservations(List observations) { + this.observations = observations; + } + + @Override + public RedisSentinelMasterDiscovery.MasterObservation query( + RedisSentinelMasterDiscovery.SentinelEndpoint sentinel, String masterName) { + return observations.get(index++); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnectorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnectorTest.java new file mode 100644 index 0000000..3c3ed08 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSentinelRuntimeConnectorTest.java @@ -0,0 +1,411 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisCredentialsProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import io.lettuce.core.RedisURI; +import io.lettuce.core.SslOptions; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RedisSentinelRuntimeConnectorTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final RedisClientRuntimeSettings SETTINGS = + new RedisClientRuntimeSettings( + "sentinel-runtime", + Duration.ofMillis(200), + Duration.ofMillis(300), + Duration.ofMillis(200), + Duration.ofSeconds(1), + Duration.ofMillis(500), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void discoveryResolvesOnlySentinelMaterialAndDestroysItsCredentialOwnerOnSuccess() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + AtomicReference capturedUris = new AtomicReference<>(); + AtomicInteger dataOpenCalls = new AtomicInteger(); + RedisSentinelRuntimeConnector connector = + connector( + credentials, + trust, + (deployment, uris, settings, options) -> { + capturedUris.set(uris); + return new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379); + }, + (deploymentId, uri, identity, owner, settings, maximumBulkBytes, dataTls) -> { + dataOpenCalls.incrementAndGet(); + return new RedisDormantCommandRuntime(deploymentId); + }); + + RedisSentinelDiscoveredRoute discovered = connector.discover(sentinel()); + + assertThat(discovered.identity()) + .isEqualTo( + RedisRouteIdentity.sentinel( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379))); + assertThat(credentials.references).containsExactly("secret://redis/sentinel/password"); + assertThat(trust.references).containsExactly("secret://redis/sentinel/ca"); + assertThat(dataOpenCalls).hasValue(0); + assertThat(capturedUris.get().discoveryUris()) + .allSatisfy( + uri -> + assertThat((DestroyableRedisCredentialsProvider) uri.getCredentialsProvider()) + .satisfies(provider -> assertThat(provider.isDestroyed()).isTrue())); + } + + @Test + void discoveryFailureDestroysDiscoveryOwnerWithoutResolvingDataMaterial() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + AtomicReference capturedUris = new AtomicReference<>(); + RedisSentinelRuntimeConnector connector = + connector( + credentials, + trust, + (deployment, uris, settings, options) -> { + capturedUris.set(uris); + throw new IllegalStateException( + "sentinel-a.internal cache-master secret://redis/sentinel/password"); + }, + RedisSentinelRuntimeConnectorTest::unusedDataOpen); + + assertThatThrownBy(() -> connector.discover(sentinel())) + .isInstanceOf(RedisSentinelMasterDiscovery.DiscoveryFailedException.class) + .hasMessage("Redis Sentinel master discovery failed") + .hasNoCause() + .hasMessageNotContaining("sentinel-a.internal") + .hasMessageNotContaining("cache-master") + .hasMessageNotContaining("secret://"); + + assertThat(credentials.references).containsExactly("secret://redis/sentinel/password"); + assertThat(trust.references).containsExactly("secret://redis/sentinel/ca"); + assertThat(capturedUris.get().discoveryUris()) + .allSatisfy( + uri -> + assertThat((DestroyableRedisCredentialsProvider) uri.getCredentialsProvider()) + .satisfies(provider -> assertThat(provider.isDestroyed()).isTrue())); + } + + @Test + void exactDataConnectResolvesOnlyDataMaterialAndNeverQueriesSentinel() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + AtomicInteger sentinelQueries = new AtomicInteger(); + AtomicReference openedUri = new AtomicReference<>(); + AtomicReference openedTls = new AtomicReference<>(); + RedisSentinelRuntimeConnector connector = + connector( + credentials, + trust, + (deployment, uris, settings, options) -> { + sentinelQueries.incrementAndGet(); + throw new AssertionError("connect must not rediscover Sentinel"); + }, + (deploymentId, uri, identity, owner, settings, maximumBulkBytes, dataTls) -> { + openedUri.set(uri); + openedTls.set(dataTls); + return new OwnerClosingRuntime(deploymentId, identity, owner); + }); + + RedisRoutableCommandRuntime runtime = + connector.connect( + sentinel(), + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379))); + + assertThat(sentinelQueries).hasValue(0); + assertThat(credentials.references).containsExactly("secret://redis/data/password"); + assertThat(trust.references).containsExactly("secret://redis/data/ca"); + assertThat(openedTls.get()).isNotNull(); + assertThat(openedUri.get().getHost()).isEqualTo("redis-primary.internal"); + assertThat(openedUri.get().getPort()).isEqualTo(6379); + assertThat(openedUri.get().getDatabase()).isEqualTo(2); + assertThat(openedUri.get().getSentinelMasterId()).isNull(); + assertThat(openedUri.get().getSentinels()).isEmpty(); + DestroyableRedisCredentialsProvider dataCredentials = + (DestroyableRedisCredentialsProvider) openedUri.get().getCredentialsProvider(); + assertThat(dataCredentials.isDestroyed()).isFalse(); + + runtime.close(); + runtime.close(); + + assertThat(dataCredentials.isDestroyed()).isTrue(); + } + + @Test + void unapprovedDataEndpointFailsBeforeEveryMaterialAndNativeClientSideEffect() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + AtomicInteger sentinelQueries = new AtomicInteger(); + AtomicInteger dataOpenCalls = new AtomicInteger(); + RedisSentinelRuntimeConnector connector = + connector( + credentials, + trust, + (deployment, uris, settings, options) -> { + sentinelQueries.incrementAndGet(); + throw new AssertionError("connect must not rediscover Sentinel"); + }, + (deploymentId, uri, identity, owner, settings, maximumBulkBytes, dataTls) -> { + dataOpenCalls.incrementAndGet(); + return new RedisDormantCommandRuntime(deploymentId); + }); + + assertThatThrownBy( + () -> + connector.connect( + sentinel(), + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint( + "unapproved.internal", 6380)))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Redis Sentinel data route is not approved") + .hasNoCause() + .hasMessageNotContaining("unapproved.internal") + .hasMessageNotContaining("6380") + .hasMessageNotContaining("sentinel-main") + .hasMessageNotContaining("secret://"); + + assertThat(credentials.references).isEmpty(); + assertThat(trust.references).isEmpty(); + assertThat(sentinelQueries).hasValue(0); + assertThat(dataOpenCalls).hasValue(0); + } + + @Test + void discoveredRouteHasStableIdentityAndConstantRedactedRendering() { + RedisSentinelDiscoveredRoute first = + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + RedisSentinelDiscoveredRoute same = + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + RedisSentinelDiscoveredRoute different = + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-replica-a.internal", 6379)); + + assertThat(first.identity()).isEqualTo(same.identity()).isNotEqualTo(different.identity()); + assertThat(first.toString()).isEqualTo("redis-sentinel-discovered-route[redacted]"); + assertThat(first.toString()) + .doesNotContain( + "redis-primary.internal", + "redis-replica-a.internal", + "6379", + "sentinel-main", + "secret://"); + } + + @Test + void dataOpenFailureDestroysTheCredentialOwnerAndSanitizesTheFailure() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + AtomicReference capturedOwner = new AtomicReference<>(); + AtomicReference capturedCredentials = + new AtomicReference<>(); + RedisSentinelRuntimeConnector connector = + connector( + credentials, + trust, + (deployment, uris, settings, options) -> { + throw new AssertionError("connect must not rediscover Sentinel"); + }, + (deploymentId, uri, identity, owner, settings, maximumBulkBytes, dataTls) -> { + capturedOwner.set(owner); + capturedCredentials.set( + (DestroyableRedisCredentialsProvider) uri.getCredentialsProvider()); + throw new IllegalStateException( + "redis-primary.internal secret://redis/data/password raw open failure"); + }); + + assertThatThrownBy( + () -> + connector.connect( + sentinel(), + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint( + "redis-primary.internal", 6379)))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Redis Sentinel data runtime opening failed") + .hasNoCause() + .hasMessageNotContaining("redis-primary.internal") + .hasMessageNotContaining("secret://") + .hasMessageNotContaining("raw open failure"); + + assertThat(capturedCredentials.get().isDestroyed()).isTrue(); + capturedOwner.get().close(); + assertThat(capturedCredentials.get().isDestroyed()).isTrue(); + } + + @Test + void temporaryDataConnectionFailureKeepsItsRetryableSanitizedType() { + CapturingCredentialProvider credentials = new CapturingCredentialProvider(); + CapturingTrustProvider trust = new CapturingTrustProvider(); + RedisSentinelRuntimeConnector connector = + connector( + credentials, + trust, + (deployment, uris, settings, options) -> { + throw new AssertionError("connect must not rediscover Sentinel"); + }, + (deploymentId, uri, identity, owner, settings, maximumBulkBytes, dataTls) -> { + throw new RedisTemporaryConnectionException(); + }); + + assertThatThrownBy( + () -> + connector.connect( + sentinel(), + RedisSentinelDiscoveredRoute.fromQuorum( + new RedisSentinelMasterDiscovery.DataEndpoint( + "redis-primary.internal", 6379)))) + .isInstanceOf(RedisTemporaryConnectionException.class) + .hasMessage("Redis topology is temporarily unavailable") + .hasNoCause(); + } + + private static RedisSentinelRuntimeConnector connector( + RedisCredentialMaterialProvider credentials, + RedisTrustMaterialProvider trust, + DefaultRedisSentinelRuntimeConnector.SentinelDiscovery discovery, + DefaultRedisSentinelRuntimeConnector.DataRuntimeOpener dataRuntimeOpener) { + return new DefaultRedisSentinelRuntimeConnector( + SETTINGS, 16_384, credentials, trust, CLOCK, discovery, dataRuntimeOpener); + } + + private static RedisRoutableCommandRuntime unusedDataOpen( + String deploymentId, + RedisURI uri, + RedisRouteIdentity identity, + RedisLettuceUris.SentinelData owner, + RedisClientRuntimeSettings settings, + int maximumBulkBytes, + SslOptions dataTls) { + throw new AssertionError("discovery must not open a data runtime"); + } + + private static RedisDeploymentSettings.Sentinel sentinel() { + return new RedisDeploymentSettings.Sentinel( + "sentinel-main", + 2, + "cache-master", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/sentinel/ca"), + new RedisDeploymentSettings.Authentication("data-runtime", "secret://redis/data/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/data/ca")); + } + + private static byte[] validPem() { + try { + return RedisSentinelRuntimeConnectorTest.class + .getResourceAsStream("/redis-test-ca.pem") + .readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException(exception); + } + } + + private static final class CapturingCredentialProvider + implements RedisCredentialMaterialProvider { + + private final List references = new ArrayList<>(); + + @Override + public VersionedRedisCredentialMaterial resolve(RedisSecretReference reference) { + references.add(reference.valueForResolution()); + return new VersionedRedisCredentialMaterial( + "credential-v1", + NOW.plusSeconds(3600), + DestroyableRedisSecret.from("password".toCharArray())); + } + } + + private static final class CapturingTrustProvider implements RedisTrustMaterialProvider { + + private final List references = new ArrayList<>(); + + @Override + public VersionedRedisTrustMaterial resolve(RedisSecretReference reference) { + references.add(reference.valueForResolution()); + return new VersionedRedisTrustMaterial( + "trust-v1", NOW.plusSeconds(3600), DestroyableRedisPem.from(validPem())); + } + } + + private static final class OwnerClosingRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final RedisRouteIdentity identity; + private final RedisLettuceUris owner; + + private OwnerClosingRuntime( + String deploymentId, RedisRouteIdentity identity, RedisLettuceUris owner) { + this.deploymentId = deploymentId; + this.identity = identity; + this.owner = owner; + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public RedisRouteIdentity routeIdentity() { + return identity; + } + + @Override + public void probe(Duration timeout) {} + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public void close() { + owner.close(); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfigTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfigTest.java new file mode 100644 index 0000000..8c51438 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionConfigTest.java @@ -0,0 +1,127 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class RedisSessionConfigTest { + + @Test + void jwtModeCreatesNoSessionStoreOrRepositorySideEffect() { + new ApplicationContextRunner() + .withUserConfiguration(RedisSessionConfig.class) + .withPropertyValues("ca-skeleton.security.auth-mode=jwt") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(RedisLuaVersionedSessionStore.class); + assertThat(context).doesNotHaveBean(RedisVersionedSessionRepository.class); + }); + } + + @Test + void clusterSessionBindingFailsBecauseRotationCannotCrossSlots() { + RedisSessionSettings settings = settings(); + RedisProviderSettings provider = + provider(RedisProviderSettings.Topology.CLUSTER, true, Duration.ofSeconds(6)); + + assertThatThrownBy(() -> RedisSessionConfig.validateActivation(settings, provider)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cross-slot"); + } + + @Test + void sessionRoleMustBeRequiredAndTombstoneMustOutliveDrain() { + assertThatThrownBy( + () -> + RedisSessionConfig.validateActivation( + settings(), + provider( + RedisProviderSettings.Topology.STANDALONE, false, Duration.ofSeconds(6)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("required"); + + assertThatThrownBy( + () -> + RedisSessionConfig.validateActivation( + settings(Duration.ofSeconds(5)), + provider( + RedisProviderSettings.Topology.STANDALONE, true, Duration.ofSeconds(6)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("drain"); + } + + private static RedisSessionSettings settings() { + return settings(Duration.ofMinutes(5)); + } + + private static RedisSessionSettings settings(Duration tombstoneTimeToLive) { + return new RedisSessionSettings( + "secret://environment/APP_SESSION_REDIS_KEY_HMAC_SECRET", + "service", + "production", + 1, + 1, + Duration.ofMinutes(30), + Duration.ofHours(8), + Duration.ofMinutes(1), + tombstoneTimeToLive, + 32_768, + 64, + 8_192); + } + + private static RedisProviderSettings provider( + RedisProviderSettings.Topology topology, boolean required, Duration drainTimeout) { + RedisProviderSettings.EndpointProperties endpoint = + new RedisProviderSettings.EndpointProperties("session.internal", 6379); + RedisProviderSettings.DeploymentProperties deployment = + new RedisProviderSettings.DeploymentProperties( + topology, + topology == RedisProviderSettings.Topology.STANDALONE + ? new RedisProviderSettings.StandaloneProperties(List.of(endpoint)) + : null, + null, + topology == RedisProviderSettings.Topology.CLUSTER + ? new RedisProviderSettings.ClusterProperties(List.of(endpoint)) + : null, + 0, + new RedisProviderSettings.AuthenticationProperties( + "session", "secret://environment/APP_SESSION_REDIS_PASSWORD"), + new RedisProviderSettings.TlsProperties( + true, true, "secret://environment/APP_SESSION_REDIS_TRUST_PEM")); + RedisProviderSettings.RuntimeProperties defaults = + new RedisProviderSettings.RuntimeProperties( + null, + null, + null, + null, + null, + null, + null, + 0, + 0, + null, + 0, + 131_072, + 0, + drainTimeout, + null, + null, + null, + null); + return new RedisProviderSettings( + Map.of("session-main", deployment), + Map.of(RedisRole.SESSION, new RedisRoleBinding("session-main", required, "noeviction")), + false, + defaults); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodecTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodecTest.java new file mode 100644 index 0000000..6e6fb44 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionEnvelopeCodecTest.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RedisSessionEnvelopeCodecTest { + + private final RedisSessionEnvelopeCodec codec = new RedisSessionEnvelopeCodec(4096, 8, 1024); + + @Test + void roundTripsTheExplicitPrimitiveAllowlistWithTheCurrentVersion() { + Map attributes = new LinkedHashMap<>(); + attributes.put("subject", "user-42"); + attributes.put("elevated", true); + attributes.put("attempts", 3); + attributes.put("security-revision", 42L); + attributes.put("authenticated-at", Instant.parse("2026-07-29T00:00:00Z")); + attributes.put("correlation", UUID.fromString("018f47ff-8c31-7d66-bfa4-4c277c1a4e87")); + RedisSessionSnapshot snapshot = + new RedisSessionSnapshot( + Instant.parse("2026-07-29T00:00:00Z"), + Instant.parse("2026-07-29T00:01:00Z"), + Instant.parse("2026-07-29T08:00:00Z"), + Duration.ofMinutes(30), + 7, + attributes); + + byte[] encoded = codec.encode(snapshot); + + assertThat(codec.decode(encoded)).isEqualTo(snapshot); + assertThat(Arrays.copyOf(encoded, 4)) + .containsExactly((byte) 'R', (byte) 'S', (byte) 'S', (byte) 'N'); + assertThat( + Arrays.equals( + Arrays.copyOf(encoded, 4), + new byte[] {(byte) 0xac, (byte) 0xed, (byte) 0x00, (byte) 0x05})) + .isFalse(); + } + + @Test + void readsThePreviousEnvelopeButAlwaysWritesTheCurrentEnvelope() { + RedisSessionSnapshot snapshot = + new RedisSessionSnapshot( + Instant.EPOCH, + Instant.EPOCH.plusSeconds(1), + Instant.EPOCH.plusSeconds(60), + Duration.ofSeconds(30), + 1, + Map.of("subject", "user-1")); + + assertThat(codec.decode(codec.encodePreviousVersionForTest(snapshot))).isEqualTo(snapshot); + assertThat(codec.version(codec.encode(snapshot))).isEqualTo(2); + } + + @Test + void rejectsUnknownAttributeTypesOversizeAndCorruptionInsteadOfInventingASession() { + RedisSessionSnapshot unknown = + new RedisSessionSnapshot( + Instant.EPOCH, + Instant.EPOCH, + Instant.EPOCH.plusSeconds(60), + Duration.ofSeconds(30), + 1, + Map.of("forbidden", new Object())); + assertThatThrownBy(() -> codec.encode(unknown)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowlist"); + + byte[] corrupted = + codec.encode( + new RedisSessionSnapshot( + Instant.EPOCH, + Instant.EPOCH, + Instant.EPOCH.plusSeconds(60), + Duration.ofSeconds(30), + 1, + Map.of("subject", "user-1"))); + corrupted[corrupted.length - 1] ^= 1; + assertThatThrownBy(() -> codec.decode(corrupted)) + .isInstanceOf(RedisSessionCorruptPayloadException.class); + + RedisSessionEnvelopeCodec tiny = new RedisSessionEnvelopeCodec(64, 1, 16); + assertThatThrownBy( + () -> + tiny.encode( + new RedisSessionSnapshot( + Instant.EPOCH, + Instant.EPOCH, + Instant.EPOCH.plusSeconds(60), + Duration.ofSeconds(30), + 1, + Map.of("subject", "0123456789abcdefg")))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettingsTest.java new file mode 100644 index 0000000..69ef84f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionSettingsTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +class RedisSessionSettingsTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(PropertiesConfig.class); + + @Test + void bindsAProductionBoundedBaselineWithoutRawSecretMaterial() { + runner + .withPropertyValues( + "ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference=secret://environment/APP_SESSION_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.security.redis-session.namespace-application=service", + "ca-skeleton.capabilities.security.redis-session.namespace-environment=production") + .run( + context -> { + assertThat(context).hasNotFailed(); + RedisSessionSettings settings = context.getBean(RedisSessionSettings.class); + assertThat(settings.idleTimeout()).isEqualTo(Duration.ofMinutes(30)); + assertThat(settings.absoluteLifetime()).isEqualTo(Duration.ofHours(8)); + assertThat(settings.touchInterval()).isEqualTo(Duration.ofMinutes(1)); + assertThat(settings.tombstoneTimeToLive()).isEqualTo(Duration.ofMinutes(5)); + assertThat(settings.maximumEnvelopeBytes()).isEqualTo(32_768); + }); + } + + @Test + void rejectsRawSecretAndUnsafeTemporalOrEnvelopeBounds() { + assertThatThrownBy( + () -> + new RedisSessionSettings( + "raw-secret", + "service", + "production", + 1, + 1, + Duration.ofMinutes(30), + Duration.ofHours(8), + Duration.ofMinutes(1), + Duration.ofMinutes(5), + 65_536, + 64, + 8_192)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reference"); + + assertThatThrownBy( + () -> + new RedisSessionSettings( + "secret://environment/APP_SESSION_REDIS_KEY_HMAC_SECRET", + "service", + "production", + 1, + 1, + Duration.ofMinutes(30), + Duration.ofHours(8), + Duration.ofMinutes(30), + Duration.ofMinutes(5), + 65_536, + 64, + 8_192)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("touch"); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RedisSessionSettings.class) + static class PropertiesConfig {} +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSpringLifecycleTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSpringLifecycleTest.java new file mode 100644 index 0000000..bf0e0b1 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSpringLifecycleTest.java @@ -0,0 +1,404 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import dev.caskeleton.application.lease.DistributedLeasePort; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.DestructionAwareBeanPostProcessor; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.session.SessionRepository; + +class RedisSpringLifecycleTest { + + private static final Set LIFECYCLE_BEANS = + Set.of( + "redisCanonicalDefaultCacheInvalidationSubscription", + "redisCanonicalDefaultCacheRegion", + "distributedRateLimiter", + "redisIdempotencyStoreV2", + "distributedLeasePort", + "redisLuaVersionedSessionStore", + "redisCanonicalRoleRegistry"); + + @Test + void realRedisConfigurationGraphDestroysEveryCapabilityBeforeRegistryAndRuntimes() { + List order = new CopyOnWriteArrayList<>(); + RuntimeFactory runtimes = new RuntimeFactory(order); + DestructionRecorder destructionRecorder = new DestructionRecorder(order); + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + AtomicReference registry = new AtomicReference<>(); + AtomicReference cache = new AtomicReference<>(); + AtomicReference rate = new AtomicReference<>(); + AtomicReference idempotency = new AtomicReference<>(); + AtomicReference lease = new AtomicReference<>(); + AtomicReference session = new AtomicReference<>(); + + new ApplicationContextRunner() + .withUserConfiguration( + RedisCanonicalConfig.class, + RedisCanonicalCacheConfig.class, + RedisRateLimitConfig.class, + RedisIdempotencyConfig.class, + RedisEfficiencyLeaseConfig.class, + RedisSessionConfig.class) + .withBean(RedisRuntimeConnector.class, () -> runtimes::connect) + .withBean( + RedisCredentialMaterialProvider.class, RedisSpringLifecycleTest::credentialProvider) + .withBean( + Clock.class, () -> Clock.fixed(Instant.parse("2026-07-29T00:00:00Z"), ZoneOffset.UTC)) + .withBean(SimpleMeterRegistry.class, () -> meterRegistry) + .withBean(DestructionRecorder.class, () -> destructionRecorder) + .withPropertyValues(properties()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(RedisCapabilityObservationPort.class); + assertThat(context).hasSingleBean(RedisCanonicalRoleRegistry.class); + assertThat(context).hasSingleBean(RedisCacheRegionRuntime.class); + assertThat(context).hasSingleBean(RedisCacheInvalidationSubscription.class); + assertThat(context).hasSingleBean(EdgeRateLimitPort.class); + assertThat(context).hasSingleBean(IdempotencyStorePortV2.class); + assertThat(context).hasSingleBean(DistributedLeasePort.class); + assertThat(context).hasSingleBean(RedisLuaVersionedSessionStore.class); + assertThat(context).hasSingleBean(SessionRepository.class); + assertThat(runtimes.runtimes).hasSize(3); + + registry.set(context.getBean(RedisCanonicalRoleRegistry.class)); + cache.set(context.getBean(RedisCacheRegionRuntime.class)); + rate.set((RedisEdgeRateLimitProvider) context.getBean("distributedRateLimiter")); + idempotency.set( + (RedisIdempotencyStoreProvider) context.getBean("redisIdempotencyStoreV2")); + lease.set((RedisEfficiencyLeaseProvider) context.getBean("distributedLeasePort")); + session.set(context.getBean(RedisLuaVersionedSessionStore.class)); + + assertThat(registry.get().boundRoles()) + .containsExactlyInAnyOrder( + RedisRole.CACHE, RedisRole.COORDINATION, RedisRole.SESSION); + assertActualDependencyGraph(context); + }); + + assertDestroyedBeforeRegistry(order, "redisCanonicalDefaultCacheRegion"); + assertDestroyedBeforeRegistry(order, "distributedRateLimiter"); + assertDestroyedBeforeRegistry(order, "redisIdempotencyStoreV2"); + assertDestroyedBeforeRegistry(order, "distributedLeasePort"); + assertDestroyedBeforeRegistry(order, "redisLuaVersionedSessionStore"); + assertThat(index(order, "subscription-delegate-close")) + .isLessThan(index(order, "before-destroy:redisCanonicalDefaultCacheRegion")); + for (String deployment : List.of("cache-main", "coord-main", "session-main")) { + assertThat(index(order, "before-destroy:redisCanonicalRoleRegistry")) + .isLessThan(index(order, "runtime-close:" + deployment)); + } + + assertThat(rate.get().destroyed()).isTrue(); + assertThat(idempotency.get().destroyed()).isTrue(); + assertThat(lease.get().destroyed()).isTrue(); + assertThat(session.get().destroyed()).isTrue(); + assertThat(registry.get().isClosed()).isTrue(); + assertThatThrownBy(() -> cache.get().lookup("closed")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + assertThat(runtimes.subscriptionCloses).hasValue(1); + runtimes.runtimes.values().forEach(runtime -> assertThat(runtime.closes).hasValue(1)); + for (RedisRole role : RedisRole.values()) { + assertThat( + meterRegistry + .get("redis.capability.lifecycle.drain.total") + .tags( + "role", + role.name().toLowerCase(java.util.Locale.ROOT), + "drain_outcome", + "drained") + .counter() + .count()) + .isEqualTo(1); + } + + registry.get().close(); + for (RedisRole role : RedisRole.values()) { + registry.get().router(role).close(); + } + registry.get().close(); + assertThat(runtimes.subscriptionCloses).hasValue(1); + runtimes.runtimes.values().forEach(runtime -> assertThat(runtime.closes).hasValue(1)); + } + + private static void assertActualDependencyGraph(ConfigurableApplicationContext context) { + assertThat(context.getBeanFactory().getDependentBeans("redisCanonicalRoleRegistry")) + .contains( + "redisCanonicalDefaultCacheRegion", + "redisCanonicalDefaultCacheInvalidationSubscription", + "distributedRateLimiter", + "redisIdempotencyStoreV2", + "distributedLeasePort", + "redisLuaVersionedSessionStore"); + assertThat(context.getBeanFactory().getDependentBeans("redisCanonicalDefaultCacheRegion")) + .contains("redisCanonicalDefaultCacheInvalidationSubscription"); + } + + private static void assertDestroyedBeforeRegistry(List order, String beanName) { + assertThat(index(order, "before-destroy:" + beanName)) + .isLessThan(index(order, "before-destroy:redisCanonicalRoleRegistry")); + } + + private static int index(List order, String event) { + assertThat(order).as(event).contains(event); + return order.indexOf(event); + } + + private static RedisCredentialMaterialProvider credentialProvider() { + return ignored -> { + byte[] secret = new byte[32]; + Arrays.fill(secret, (byte) 7); + char[] encoded = java.util.Base64.getEncoder().encodeToString(secret).toCharArray(); + Arrays.fill(secret, (byte) 0); + try { + return new VersionedRedisCredentialMaterial( + "composition-v1", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from(encoded)); + } finally { + Arrays.fill(encoded, '\0'); + } + }; + } + + private static String[] properties() { + return new String[] { + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference=secret://environment/CACHE_KEY_HMAC", + "ca-skeleton.capabilities.cache.regions.default.l1.enabled=true", + "ca-skeleton.capabilities.rate-limit.provider=redis", + "ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference=secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.rate-limit.namespace-environment=test", + "ca-skeleton.capabilities.rate-limit.policies.api-default.revision=r1", + "ca-skeleton.capabilities.rate-limit.policies.api-default.algorithm=fixed-window", + "ca-skeleton.capabilities.rate-limit.policies.api-default.limit=100", + "ca-skeleton.capabilities.rate-limit.policies.api-default.window=1s", + "ca-skeleton.capabilities.rate-limit.policies.api-default.maximum-cost=10", + "ca-skeleton.capabilities.rate-limit.policies.api-default.cleanup-grace=5s", + "ca-skeleton.capabilities.rate-limit.policies.api-default.maximum-clock-regression=250ms", + "ca-skeleton.capabilities.idempotency.provider=redis", + "ca-skeleton.capabilities.idempotency.key-hmac-secret-reference=secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.idempotency.namespace-environment=test", + "ca-skeleton.capabilities.lease.provider=redis", + "ca-skeleton.capabilities.lease.key-hmac-secret-reference=secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.lease.namespace-environment=test", + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference=secret://environment/APP_SESSION_REDIS_KEY_HMAC_SECRET", + "ca-skeleton.capabilities.security.redis-session.namespace-application=service", + "ca-skeleton.capabilities.security.redis-session.namespace-environment=test", + "ca-skeleton.providers.redis.deployments.cache-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].host=cache.internal", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.cache-main.database=0", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.username=cache-runtime", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.password-reference=secret://environment/APP_CACHE_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.cache-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.trust-bundle-reference=secret://environment/APP_CACHE_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.deployments.coord-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.coord-main.standalone.endpoints[0].host=coord.internal", + "ca-skeleton.providers.redis.deployments.coord-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.coord-main.database=0", + "ca-skeleton.providers.redis.deployments.coord-main.authentication.username=coord-runtime", + "ca-skeleton.providers.redis.deployments.coord-main.authentication.password-reference=secret://environment/APP_RATE_LIMIT_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.coord-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.coord-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.coord-main.tls.trust-bundle-reference=secret://environment/APP_RATE_LIMIT_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.deployments.session-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.session-main.standalone.endpoints[0].host=session.internal", + "ca-skeleton.providers.redis.deployments.session-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.session-main.database=0", + "ca-skeleton.providers.redis.deployments.session-main.authentication.username=session-runtime", + "ca-skeleton.providers.redis.deployments.session-main.authentication.password-reference=secret://environment/APP_SESSION_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.session-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.session-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.session-main.tls.trust-bundle-reference=secret://environment/APP_SESSION_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main", + "ca-skeleton.providers.redis.roles.cache.required=false", + "ca-skeleton.providers.redis.roles.cache.expected-eviction=allkeys-lfu", + "ca-skeleton.providers.redis.roles.coordination.deployment-id=coord-main", + "ca-skeleton.providers.redis.roles.coordination.required=true", + "ca-skeleton.providers.redis.roles.coordination.expected-eviction=noeviction", + "ca-skeleton.providers.redis.roles.session.deployment-id=session-main", + "ca-skeleton.providers.redis.roles.session.required=true", + "ca-skeleton.providers.redis.roles.session.expected-eviction=noeviction" + }; + } + + private static final class DestructionRecorder implements DestructionAwareBeanPostProcessor { + + private final List order; + + private DestructionRecorder(List order) { + this.order = order; + } + + @Override + public void postProcessBeforeDestruction(Object bean, String beanName) { + if (LIFECYCLE_BEANS.contains(beanName)) { + order.add("before-destroy:" + beanName); + } + } + + @Override + public boolean requiresDestruction(Object bean) { + return true; + } + } + + private static final class RuntimeFactory { + + private final List order; + private final Map runtimes = new HashMap<>(); + private final AtomicInteger subscriptionCloses = new AtomicInteger(); + + private RuntimeFactory(List order) { + this.order = order; + } + + private RedisRoutableCommandRuntime connect(RedisDeploymentSettings deployment) { + RecordingRuntime runtime = + new RecordingRuntime(deployment.deploymentId(), order, subscriptionCloses); + if (runtimes.putIfAbsent(deployment.deploymentId(), runtime) != null) { + throw new AssertionError("deployment connected more than once"); + } + return runtime; + } + } + + private static final class RecordingRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final List order; + private final AtomicInteger subscriptionCloses; + private final Map values = new HashMap<>(); + private final Set loaded = new HashSet<>(); + private final Map programsBySha = new HashMap<>(); + private final AtomicInteger closes = new AtomicInteger(); + + private RecordingRuntime( + String deploymentId, List order, AtomicInteger subscriptionCloses) { + this.deploymentId = deploymentId; + this.order = order; + this.subscriptionCloses = subscriptionCloses; + RedisProgramCatalog.unified() + .descriptors() + .forEach( + descriptor -> + programsBySha.put( + RedisScriptRecovery.sha1(descriptor.scriptBytes()), descriptor.id())); + } + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + byte[] value = + values.get(new String(RedisPhysicalKey.WireCodec.copy(key), StandardCharsets.UTF_8)); + return value == null ? null : value.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + values.put( + new String(RedisPhysicalKey.WireCodec.copy(key), StandardCharsets.UTF_8), + value.copyEncoded()); + } + + @Override + public long delete(RedisPhysicalKey key) { + return values.remove(new String(RedisPhysicalKey.WireCodec.copy(key), StandardCharsets.UTF_8)) + == null + ? 0 + : 1; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + String sha1 = invocation.sha1(); + if (!loaded.contains(sha1)) { + throw new RedisNoScriptException(); + } + if (sha1.equals(RedisScriptRecovery.sha1(RedisSemanticAclProbeCatalog.scriptBytes()))) { + return RedisCatalogProgramReply.value("ACL_OK".getBytes(StandardCharsets.US_ASCII)); + } + RedisProgramId id = programsBySha.get(sha1); + if (invocation.replyShape() != RedisCatalogProgramInvocation.ReplyShape.MULTI) { + return RedisCatalogProgramReply.value("EXISTS".getBytes(StandardCharsets.US_ASCII)); + } + RedisProgramDescriptor descriptor = RedisProgramCatalog.unified().descriptor(id); + String status = + switch (id) { + case RATE_FIXED_WINDOW_V2, IDEMPOTENCY_CLAIM_V1, LEASE_ACQUIRE_V1 -> + "STATE_INCOMPATIBLE"; + case SESSION_CREATE_V1 -> "TOMBSTONED"; + default -> throw new AssertionError("unexpected semantic program: " + id); + }; + java.util.ArrayList reply = new java.util.ArrayList<>(); + reply.add(status.getBytes(StandardCharsets.US_ASCII)); + while (reply.size() < descriptor.replyFieldCount()) { + reply.add(new byte[0]); + } + return RedisCatalogProgramReply.multi(List.copyOf(reply)); + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + loaded.add(invocation.sha1()); + return invocation.sha1(); + } + + @Override + public long publish(byte[] channel, byte[] message) { + return 1; + } + + @Override + public RedisInvalidationTransport.Subscription subscribe( + byte[] channel, RedisInvalidationTransport.Listener listener) { + return () -> { + subscriptionCloses.incrementAndGet(); + order.add("subscription-delegate-close"); + }; + } + + @Override + public void close() { + if (closes.incrementAndGet() == 1) { + order.add("runtime-close:" + deploymentId); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java index 5fa31b1..29536c2 100644 --- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStringCacheRegionTest.java @@ -6,39 +6,100 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.outbound.cache.redis.key.RedisKeyNamespace; import dev.caskeleton.application.cache.AuthoritativeAbsence; +import dev.caskeleton.application.cache.CacheAsideExecutor; +import dev.caskeleton.application.cache.CacheAsidePolicy; import dev.caskeleton.application.cache.CacheInvalidationOutcome; import dev.caskeleton.application.cache.CacheLookup; import dev.caskeleton.application.cache.CacheRecordIntent; import dev.caskeleton.application.cache.CacheRecordMetadata; import dev.caskeleton.application.cache.CacheRecordOutcome; +import dev.caskeleton.application.cache.CacheRefreshCoordinationPolicy; +import dev.caskeleton.application.cache.CacheResult; +import dev.caskeleton.application.cache.SourceLoadOutcome; import java.nio.ByteBuffer; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class RedisStringCacheRegionTest { + private static final Instant BASE_TIME = Instant.parse("2026-07-28T00:00:00Z"); + private FakeCommands commands; + private MutableClock clock; private RedisStringCacheRegion region; + private RecordingRedisCapabilityObservations observations; @BeforeEach void setUp() { commands = new FakeCommands(); + clock = new MutableClock(BASE_TIME); + observations = new RecordingRedisCapabilityObservations(); + AtomicLong ticker = new AtomicLong(); region = new RedisStringCacheRegion( new RedisCacheRegionPolicy( new RedisKeyNamespace( "ca-skeleton", "test", "cache", "worklog", 1, 1, "entry", 512), new byte[32], + "worklog-cache-r2", Duration.ofMinutes(5), + Duration.ofMinutes(10), Duration.ofSeconds(30), + 0.0, + Duration.ofSeconds(10), 1024), - commands); + commands, + clock, + observations, + () -> ticker.getAndAdd(10)); } @Test - void recordsAndReadsPositiveEntryWithBoundedTtl() { + void emitsReturnedCacheOutcomeAndCertaintyWithoutTheSemanticKey() { + assertMiss(region.lookup("customer-email@example.test"), CacheLookup.MissReason.ABSENT, true); + commands.failure = + new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.INDETERMINATE, + "response lost for customer-email@example.test", + null); + assertThat( + region.record( + "customer-email@example.test", + "secret-value", + new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.INDETERMINATE); + + assertThat(observations.operations()) + .extracting( + RedisCapabilityObservationEvent.OperationCompleted::operation, + RedisCapabilityObservationEvent.OperationCompleted::outcome, + RedisCapabilityObservationEvent.OperationCompleted::certainty) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.LOOKUP, + RedisCapabilityObservationEvent.Outcome.MISS, + RedisCapabilityObservationEvent.Certainty.DEFINITE), + org.assertj.core.groups.Tuple.tuple( + RedisCapabilityObservationEvent.Operation.RECORD, + RedisCapabilityObservationEvent.Outcome.INDETERMINATE, + RedisCapabilityObservationEvent.Certainty.INDETERMINATE)); + assertThat(observations.operations().toString()) + .doesNotContain("customer-email", "secret-value", "response lost"); + } + + @Test + void recordsAndReadsPositiveEntryWithAbsoluteSoftAndHardExpiry() { CacheRecordOutcome outcome = region.record( "tenant-1:work-1", @@ -46,11 +107,19 @@ class RedisStringCacheRegionTest { new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED); - assertThat(commands.lastTtl).isEqualTo(Duration.ofMinutes(5)); + assertThat(commands.lastTtl).isEqualTo(Duration.ofMinutes(10)); assertThat(new String(commands.lastKey, UTF_8)).doesNotContain("tenant-1"); - assertThat(region.lookup("tenant-1:work-1")) - .isEqualTo( - new CacheLookup.Hit<>("cached-value", CacheLookup.Freshness.FRESH, "revision-1")); + assertHit( + region.lookup("tenant-1:work-1"), + "cached-value", + CacheLookup.Freshness.FRESH, + "revision-1", + BASE_TIME.plus(Duration.ofMinutes(5)), + BASE_TIME.plus(Duration.ofMinutes(10))); + + RedisCacheEnvelopeCodec.Positive decoded = + (RedisCacheEnvelopeCodec.Positive) RedisCacheEnvelopeCodec.decode(commands.value, 1024); + assertThat(Duration.between(BASE_TIME, decoded.hardExpiresAt())).isEqualTo(commands.lastTtl); } @Test @@ -64,20 +133,177 @@ class RedisStringCacheRegionTest { assertThat(outcome).isEqualTo(CacheRecordOutcome.RECORDED); assertThat(commands.lastTtl).isEqualTo(Duration.ofSeconds(30)); assertThat(region.lookup("tenant-1:missing")) - .isEqualTo(new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND)); + .isEqualTo( + new CacheLookup.NegativeHit<>( + AuthoritativeAbsence.NOT_FOUND, BASE_TIME.plusSeconds(30))); + } + + @Test + void classifiesExactFreshStaleAndExpiredBoundariesWithInjectedClock() { + region.record( + "boundary", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + + clock.advance(Duration.ofMinutes(5)); + assertHit( + region.lookup("boundary"), + "value", + CacheLookup.Freshness.STALE, + "revision-1", + BASE_TIME.plus(Duration.ofMinutes(5)), + BASE_TIME.plus(Duration.ofMinutes(10))); + + clock.advance(Duration.ofMinutes(5)); + assertMiss(region.lookup("boundary"), CacheLookup.MissReason.EXPIRED, true); + } + + @Test + void expiresNegativeEntryAtItsExactHardBoundary() { + region.recordAbsent( + "negative-boundary", + AuthoritativeAbsence.NOT_APPLICABLE, + new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + + clock.advance(Duration.ofSeconds(30)); + + assertMiss(region.lookup("negative-boundary"), CacheLookup.MissReason.EXPIRED, true); + } + + @Test + void appliesDeterministicBoundedJitterAndKeepsPositiveExpiriesOnOneFactor() { + RedisCacheRegionPolicy jitteredPolicy = + new RedisCacheRegionPolicy( + new RedisKeyNamespace("ca-skeleton", "test", "cache", "jitter", 1, 1, "entry", 512), + new byte[32], + "jitter-policy-r1", + Duration.ofSeconds(100), + Duration.ofSeconds(200), + Duration.ofSeconds(50), + 0.5, + Duration.ofSeconds(1), + 1024); + FakeCommands firstCommands = new FakeCommands(); + FakeCommands secondCommands = new FakeCommands(); + RedisStringCacheRegion first = + new RedisStringCacheRegion( + jitteredPolicy, firstCommands, Clock.fixed(BASE_TIME, ZoneOffset.UTC)); + RedisStringCacheRegion second = + new RedisStringCacheRegion( + jitteredPolicy, secondCommands, Clock.fixed(BASE_TIME, ZoneOffset.UTC)); + + CacheRecordMetadata metadata = new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT); + first.record("same-key", "value", metadata); + second.record("same-key", "value", metadata); + + assertThat(firstCommands.lastTtl).isEqualTo(secondCommands.lastTtl); + assertThat(firstCommands.value).isEqualTo(secondCommands.value); + assertThat(firstCommands.lastTtl).isBetween(Duration.ofSeconds(100), Duration.ofSeconds(300)); + RedisCacheEnvelopeCodec.Positive decoded = + (RedisCacheEnvelopeCodec.Positive) + RedisCacheEnvelopeCodec.decode(firstCommands.value, 1024); + long actualSoftMillis = Duration.between(BASE_TIME, decoded.softExpiresAt()).toMillis(); + long actualHardMillis = Duration.between(BASE_TIME, decoded.hardExpiresAt()).toMillis(); + assertThat(Math.abs(actualHardMillis - (actualSoftMillis * 2L))).isLessThanOrEqualTo(1L); + assertThat(Duration.ofMillis(actualHardMillis)).isEqualTo(firstCommands.lastTtl); + } + + @Test + void enforcesHardMinimumForPositiveAndIndependentNegativeJitter() { + RedisCacheRegionPolicy policy = + new RedisCacheRegionPolicy( + new RedisKeyNamespace("ca-skeleton", "test", "cache", "minimum", 1, 1, "entry", 512), + new byte[32], + "minimum-policy-r1", + Duration.ofSeconds(5), + Duration.ofSeconds(10), + Duration.ofSeconds(10), + 0.5, + Duration.ofSeconds(9), + 1024); + byte[] physicalKey = "hmac-derived-physical-key".getBytes(UTF_8); + + RedisCacheRegionPolicy.PositiveExpiry positive = policy.positiveExpiry(physicalKey); + Duration negative = policy.negativeTimeToLive(physicalKey); + + assertThat(positive.hardTtl()).isGreaterThanOrEqualTo(Duration.ofSeconds(9)); + assertThat(negative).isGreaterThanOrEqualTo(Duration.ofSeconds(9)); + assertThat(positive.hardTtl()).isLessThanOrEqualTo(Duration.ofSeconds(15)); + assertThat(negative).isLessThanOrEqualTo(Duration.ofSeconds(15)); + assertThat(positive.hardTtl()).isNotEqualTo(negative); + } + + @Test + void rejectsInvalidTtlJitterAndMinimumPolicy() { + RedisKeyNamespace namespace = + new RedisKeyNamespace("ca-skeleton", "test", "cache", "invalid-policy", 1, 1, "entry", 512); + + assertThatThrownBy( + () -> + new RedisCacheRegionPolicy( + namespace, + new byte[32], + "revision", + Duration.ofSeconds(2), + Duration.ofSeconds(1), + Duration.ofSeconds(1), + 0.0, + Duration.ofSeconds(1), + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("soft"); + assertThatThrownBy( + () -> + new RedisCacheRegionPolicy( + namespace, + new byte[32], + "revision", + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(2), + 0.51, + Duration.ofSeconds(1), + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("jitter ratio"); + assertThatThrownBy( + () -> + new RedisCacheRegionPolicy( + namespace, + new byte[32], + "revision", + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(2), + 0.0, + Duration.ofSeconds(3), + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minimum hard TTL"); + assertThatThrownBy( + () -> + new RedisCacheRegionPolicy( + namespace, + new byte[32], + "revision", + Duration.ofDays(20), + Duration.ofDays(30), + Duration.ofDays(30), + 0.01, + Duration.ofSeconds(1), + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("30 days"); } @Test void distinguishesMissIncompatibleEnvelopeAndProviderFailure() { - assertThat(region.lookup("absent")) - .isEqualTo(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + assertMiss(region.lookup("absent"), CacheLookup.MissReason.ABSENT, true); - commands.value = new byte[] {0, 1, 2}; - assertThat(region.lookup("invalid")) - .isEqualTo( - new CacheLookup.IncompatibleSchema<>( - CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, - CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + commands.inject(new byte[] {0, 1, 2}); + assertIncompatible( + region.lookup("invalid"), + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.FAIL_FAST, + false); commands.failure = new IllegalStateException("connection unavailable"); assertThatThrownBy(() -> region.lookup("programming-error")) @@ -121,6 +347,71 @@ class RedisStringCacheRegionTest { assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INDETERMINATE); } + @Test + void onlyIfAbsentNeverOverwritesAConcurrentWriter() { + CacheLookup.Miss captured = (CacheLookup.Miss) region.lookup("key"); + assertThat( + region.record( + "key", + "newer-value", + new CacheRecordMetadata("revision-2", CacheRecordIntent.UPSERT))) + .isEqualTo(CacheRecordOutcome.RECORDED); + + assertThat( + region.record( + "key", + "stale-refill", + new CacheRecordMetadata( + "revision-1", + CacheRecordIntent.ONLY_IF_ABSENT, + dev.caskeleton.application.cache.CacheObservationToken.unavailable(), + captured.writeCondition()))) + .isEqualTo(CacheRecordOutcome.NOT_RECORDED_CONDITION); + assertHit( + region.lookup("key"), + "newer-value", + CacheLookup.Freshness.FRESH, + "revision-2", + BASE_TIME.plus(Duration.ofMinutes(5)), + BASE_TIME.plus(Duration.ofMinutes(10))); + } + + @Test + void replacesOnlyTheExactObservedEnvelopeAndPreservesAConcurrentWriter() { + region.record( + "key", "stale-value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + clock.advance(Duration.ofMinutes(5)); + CacheLookup.Hit observed = (CacheLookup.Hit) region.lookup("key"); + + assertThat( + region.record( + "key", + "refreshed-value", + new CacheRecordMetadata( + "revision-2", + CacheRecordIntent.ONLY_IF_OBSERVED, + observed.observationToken(), + observed.writeCondition()))) + .isEqualTo(CacheRecordOutcome.RECORDED); + + CacheLookup.Hit secondObservation = (CacheLookup.Hit) region.lookup("key"); + region.record( + "key", "concurrent-value", new CacheRecordMetadata("revision-3", CacheRecordIntent.UPSERT)); + + assertThat( + region.record( + "key", + "losing-refill", + new CacheRecordMetadata( + "revision-2", + CacheRecordIntent.ONLY_IF_OBSERVED, + secondObservation.observationToken(), + secondObservation.writeCondition()))) + .isEqualTo(CacheRecordOutcome.NOT_RECORDED_CONDITION); + assertThat(((CacheLookup.Hit) region.lookup("key")).value()) + .isEqualTo("concurrent-value"); + } + @Test void mapsKnownPreSendMutationFailureToDegradedUnavailable() { commands.failure = @@ -138,62 +429,242 @@ class RedisStringCacheRegionTest { } @Test - void rejectsInvalidRevisionFutureVersionAndBitCorruptionThroughTypedSchemaResults() { - commands.value = rawEnvelope((byte) 1, "r".repeat(129), "value"); - assertThat(region.lookup("invalid-revision")) - .isEqualTo( - new CacheLookup.IncompatibleSchema<>( - CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, - CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + void rejectsRetiredFutureAndBitCorruptionThroughTypedSchemaResults() { + commands.inject(versionOneEnvelope("revision-1", "value")); + assertIncompatible( + region.lookup("retired"), + CacheLookup.SchemaCategory.RETIRED_VERSION, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD, + true); - commands.value = rawEnvelope((byte) 2, "revision-1", "value"); - assertThat(region.lookup("future")) - .isEqualTo( - new CacheLookup.IncompatibleSchema<>( - CacheLookup.SchemaCategory.FUTURE_VERSION, CacheLookup.SchemaPolicy.FAIL_FAST)); + commands.inject(envelopeWithVersion((byte) 3)); + assertIncompatible( + region.lookup("future"), + CacheLookup.SchemaCategory.FUTURE_VERSION, + CacheLookup.SchemaPolicy.FAIL_FAST, + true); - commands.value = RedisCacheEnvelopeCodec.positive("value", "revision-1", 1024); + commands.inject(envelopeWithVersionAndType((byte) 2, (byte) 99)); + assertIncompatible( + region.lookup("digest-valid-structural-corruption"), + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.FAIL_FAST, + true); + + commands.inject( + RedisCacheEnvelopeCodec.positive( + "value", "revision-1", BASE_TIME.plusSeconds(1), BASE_TIME.plusSeconds(2), 1024)); commands.value[commands.value.length - 33] ^= 1; - assertThat(region.lookup("corrupt")) - .isEqualTo( - new CacheLookup.IncompatibleSchema<>( - CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, - CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + assertIncompatible( + region.lookup("corrupt"), + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.FAIL_FAST, + false); + + commands.inject( + RedisCacheEnvelopeCodec.positive( + "value", "revision-1", BASE_TIME.plusSeconds(1), BASE_TIME.plusSeconds(2), 1024)); + commands.value[4] = 1; + assertIncompatible( + region.lookup("corrupt-version-byte"), + CacheLookup.SchemaCategory.CORRUPT_ENVELOPE, + CacheLookup.SchemaPolicy.FAIL_FAST, + false); } @Test - void invalidatesExistingAndMissingEntriesSeparately() { + void invalidationAdvancesThePerKeyFenceEvenWhenNoEntryIsCurrentlyVisible() { region.record("key", "value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INVALIDATED); - assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.ALREADY_ABSENT); + assertMiss(region.lookup("key"), CacheLookup.MissReason.ABSENT, true); + assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + } + + @Test + void invalidationDuringSourceLoadMakesTheOldCapturedRefillInvisible() { + CacheAsideExecutor executor = cacheAsideExecutor(); + + CacheResult result = + executor.getOrLoad( + "key", + region, + (key, cancellation) -> { + assertThat(region.invalidate(key)).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + return new SourceLoadOutcome.Loaded<>("loaded-before-invalidation", "revision-1"); + }); + + assertThat(result) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "loaded-before-invalidation", + "revision-1", + CacheRecordOutcome.NOT_RECORDED_CONDITION)); + assertMiss(region.lookup("key"), CacheLookup.MissReason.ABSENT, true); + } + + @Test + void regionInvalidationDuringSourceLoadMakesEveryOldGenerationRefillInvisible() { + CacheAsideExecutor executor = cacheAsideExecutor(); + + CacheResult result = + executor.getOrLoad( + "key", + region, + (key, cancellation) -> { + assertThat(region.invalidateRegion()).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + return new SourceLoadOutcome.Loaded<>( + "loaded-before-mass-invalidation", "revision-1"); + }); + + assertThat(result) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "loaded-before-mass-invalidation", + "revision-1", + CacheRecordOutcome.NOT_RECORDED_CONDITION)); + assertMiss(region.lookup("key"), CacheLookup.MissReason.ABSENT, true); + } + + @Test + void expiryMissCarriesItsFenceAndSourceReloadRecordsANewEnvelope() { + region.record( + "key", "expired-value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + clock.advance(Duration.ofMinutes(10)); + CacheAsideExecutor executor = cacheAsideExecutor(); + + CacheResult result = + executor.getOrLoad( + "key", + region, + (key, cancellation) -> { + commands.expireEntries(); + return new SourceLoadOutcome.Loaded<>("reloaded-value", "revision-2"); + }); + + assertThat(result) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "reloaded-value", "revision-2", CacheRecordOutcome.RECORDED)); + assertThat(((CacheLookup.Hit) region.lookup("key")).value()) + .isEqualTo("reloaded-value"); + } + + @Test + void softLeaseNeverOverridesTheGenerationFenceWhenInvalidationWinsDuringRefresh() { + region.record( + "key", "stale-value", new CacheRecordMetadata("revision-1", CacheRecordIntent.UPSERT)); + clock.advance(Duration.ofMinutes(5)); + CacheRefreshCoordinationPolicy refreshPolicy = + new CacheRefreshCoordinationPolicy( + Duration.ofSeconds(10), + CacheRefreshCoordinationPolicy.HardMissPolicy.NORMAL_SOURCE_LOAD, + Duration.ZERO); + CacheAsideExecutor executor = + new CacheAsideExecutor<>( + new CacheAsidePolicy(16, 8, 4, Duration.ofMillis(100), Duration.ofSeconds(5), true), + clock, + region.refreshCoordinator(), + refreshPolicy); + + CacheResult result = + executor.getOrLoad( + "key", + region, + (key, cancellation) -> { + assertThat(region.invalidate(key)).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + return new SourceLoadOutcome.Loaded<>("losing-refresh", "revision-2"); + }); + + assertThat(result) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "losing-refresh", "revision-2", CacheRecordOutcome.NOT_RECORDED_CONDITION)); + assertMiss(region.lookup("key"), CacheLookup.MissReason.ABSENT, true); + assertThat(commands.refreshLeases).isEmpty(); + } + + private CacheAsideExecutor cacheAsideExecutor() { + return new CacheAsideExecutor<>( + new CacheAsidePolicy(16, 8, 4, Duration.ofMillis(100), Duration.ofSeconds(5), true), clock); + } + + private static void assertHit( + CacheLookup lookup, + String value, + CacheLookup.Freshness freshness, + String sourceRevision, + Instant softExpiresAt, + Instant hardExpiresAt) { + CacheLookup.Hit hit = (CacheLookup.Hit) lookup; + assertThat(hit.value()).isEqualTo(value); + assertThat(hit.freshness()).isEqualTo(freshness); + assertThat(hit.sourceRevision()).isEqualTo(sourceRevision); + assertThat(hit.softExpiresAt()).isEqualTo(softExpiresAt); + assertThat(hit.hardExpiresAt()).isEqualTo(hardExpiresAt); + assertThat(hit.observationToken().usable()).isTrue(); + } + + private static void assertIncompatible( + CacheLookup lookup, + CacheLookup.SchemaCategory category, + CacheLookup.SchemaPolicy policy, + boolean tokenUsable) { + CacheLookup.IncompatibleSchema incompatible = + (CacheLookup.IncompatibleSchema) lookup; + assertThat(incompatible.category()).isEqualTo(category); + assertThat(incompatible.policy()).isEqualTo(policy); + assertThat(incompatible.observationToken().usable()).isEqualTo(tokenUsable); + assertThat(incompatible.writeCondition().usable()).isTrue(); + } + + private static void assertMiss( + CacheLookup lookup, CacheLookup.MissReason reason, boolean conditionUsable) { + CacheLookup.Miss miss = (CacheLookup.Miss) lookup; + assertThat(miss.reason()).isEqualTo(reason); + assertThat(miss.writeCondition().usable()).isEqualTo(conditionUsable); } private static final class FakeCommands implements RedisBinaryCommands { + private final Map entries = new HashMap<>(); + private final Map controls = new HashMap<>(); + private final Map refreshLeases = new HashMap<>(); private byte[] lastKey; private byte[] value; + private byte[] injectedValue; private Duration lastTtl; private RuntimeException failure; @Override - public byte[] get(byte[] key) { + public byte[] get(RedisPhysicalKey key) { failIfConfigured(); - return value == null ? null : value.clone(); + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + String textKey = new String(encodedKey, UTF_8); + byte[] stored = isControlKey(textKey) ? controls.get(textKey) : entries.get(textKey); + if (stored == null && !isControlKey(textKey)) { + stored = injectedValue; + } + return stored == null ? null : stored.clone(); } @Override - public void set(byte[] key, byte[] value, Duration timeToLive) { + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { failIfConfigured(); - lastKey = key.clone(); - this.value = value.clone(); + byte[] encodedKey = RedisPhysicalKey.WireCodec.copy(key); + byte[] encodedValue = value.copyEncoded(); + lastKey = encodedKey.clone(); + this.value = encodedValue.clone(); + injectedValue = null; + entries.put(new String(encodedKey, UTF_8), encodedValue); lastTtl = timeToLive; } @Override - public long delete(byte[] key) { + public long delete(RedisPhysicalKey key) { failIfConfigured(); - if (value == null) { + byte[] removed = entries.remove(new String(RedisPhysicalKey.WireCodec.copy(key), UTF_8)); + if (removed == null) { return 0; } value = null; @@ -201,13 +672,161 @@ class RedisStringCacheRegionTest { } @Override - public byte[] evalSha(String sha1, List keys, List arguments) { - throw new UnsupportedOperationException(); + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + return RedisCatalogProgramReply.value( + atomic( + programId(invocation.sha1()), + RedisCatalogProgramInvocation.WireCodec.keys(invocation), + RedisCatalogProgramInvocation.WireCodec.arguments(invocation))); } @Override - public byte[] eval(byte[] script, List keys, List arguments) { - throw new UnsupportedOperationException(); + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + private byte[] atomic(RedisProgramId id, List keys, List arguments) { + failIfConfigured(); + return switch (id) { + case REGION_GENERATION_INIT -> initializeGeneration(keys, arguments); + case REGION_GENERATION_BUMP -> bumpGeneration(keys, arguments); + case CACHE_REFRESH_CLAIM -> claimRefresh(keys, arguments); + case COMPARE_AND_DELETE -> releaseRefresh(keys, arguments); + case SET_IF_ABSENT_WITH_TTL -> setIfAbsent(keys, arguments); + case REPLACE_IF_OBSERVED_WITH_TTL -> replaceIfObserved(keys, arguments); + default -> throw new AssertionError("unexpected program " + id); + }; + } + + private byte[] claimRefresh(List keys, List arguments) { + String key = new String(keys.getFirst(), UTF_8); + byte[] requested = state(arguments.get(0), arguments.get(1)); + byte[] current = refreshLeases.get(key); + if (current == null) { + refreshLeases.put(key, requested); + return ascii("CLAIMED"); + } + return Arrays.equals(current, requested) ? ascii("ALREADY_OWNED") : ascii("CONTENDED"); + } + + private byte[] releaseRefresh(List keys, List arguments) { + String key = new String(keys.getFirst(), UTF_8); + byte[] current = refreshLeases.get(key); + if (current == null) { + return ascii("ABSENT"); + } + if (!Arrays.equals(current, arguments.getFirst())) { + return ascii("NOT_OWNER"); + } + refreshLeases.remove(key); + return ascii("DELETED"); + } + + private byte[] initializeGeneration(List keys, List arguments) { + String key = new String(keys.getFirst(), UTF_8); + if (controls.containsKey(key)) { + return ascii("EXISTING"); + } + controls.put(key, state(arguments.getFirst(), "-".getBytes(UTF_8))); + return ascii("INITIALIZED"); + } + + private byte[] bumpGeneration(List keys, List arguments) { + String key = new String(keys.getFirst(), UTF_8); + byte[] current = controls.get(key); + byte[] operation = arguments.get(1); + if (current != null && Arrays.equals(operation(current), operation)) { + return ascii("ALREADY_APPLIED"); + } + controls.put(key, state(arguments.getFirst(), operation)); + injectedValue = null; + value = null; + return ascii("BUMPED"); + } + + private byte[] setIfAbsent(List keys, List arguments) { + String key = new String(keys.getFirst(), UTF_8); + if (entries.containsKey(key)) { + return ascii("EXISTS"); + } + lastKey = keys.getFirst().clone(); + value = arguments.getFirst().clone(); + injectedValue = null; + entries.put(key, value.clone()); + lastTtl = Duration.ofMillis(Long.parseLong(new String(arguments.get(1), UTF_8))); + return ascii("SET"); + } + + private byte[] replaceIfObserved(List keys, List arguments) { + String key = new String(keys.getFirst(), UTF_8); + byte[] current = entries.get(key); + if (current == null) { + return ascii("ABSENT"); + } + byte[] expectedDigest = arguments.getFirst(); + byte[] currentDigest = Arrays.copyOfRange(current, current.length - 32, current.length); + if (!java.security.MessageDigest.isEqual(expectedDigest, currentDigest)) { + return ascii("NOT_MATCHED"); + } + lastKey = keys.getFirst().clone(); + value = arguments.get(1).clone(); + entries.put(key, value.clone()); + lastTtl = Duration.ofMillis(Long.parseLong(new String(arguments.get(2), UTF_8))); + return ascii("REPLACED"); + } + + private void inject(byte[] envelope) { + value = envelope; + injectedValue = envelope; + } + + private void expireEntries() { + entries.clear(); + value = null; + injectedValue = null; + } + + private static boolean isControlKey(String key) { + return key.endsWith(":region-generation") || key.endsWith(":key-revision"); + } + + private static byte[] state(byte[] generation, byte[] operation) { + byte[] state = new byte[generation.length + 1 + operation.length]; + System.arraycopy(generation, 0, state, 0, generation.length); + state[generation.length] = '|'; + System.arraycopy(operation, 0, state, generation.length + 1, operation.length); + return state; + } + + private static byte[] operation(byte[] state) { + for (int index = 0; index < state.length; index++) { + if (state[index] == '|') { + return Arrays.copyOfRange(state, index + 1, state.length); + } + } + throw new AssertionError("malformed fake generation state"); + } + + private static RedisProgramId programId(String sha1) { + return RedisProgramCatalog.foundation().descriptors().stream() + .filter(descriptor -> sha1(descriptor.scriptBytes()).equals(sha1)) + .map(RedisProgramDescriptor::id) + .findFirst() + .orElseThrow(); + } + + private static String sha1(byte[] script) { + try { + return java.util.HexFormat.of() + .formatHex(java.security.MessageDigest.getInstance("SHA-1").digest(script)); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + } + + private static byte[] ascii(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.US_ASCII); } private void failIfConfigured() { @@ -217,13 +836,13 @@ class RedisStringCacheRegionTest { } } - private static byte[] rawEnvelope(byte version, String revision, String value) { + private static byte[] versionOneEnvelope(String revision, String value) { byte[] revisionBytes = revision.getBytes(UTF_8); byte[] valueBytes = value.getBytes(UTF_8); byte[] content = ByteBuffer.allocate(12 + revisionBytes.length + valueBytes.length) .putInt(0x43414348) - .put(version) + .put((byte) 1) .put((byte) 1) .putShort((short) revisionBytes.length) .putInt(valueBytes.length) @@ -238,4 +857,51 @@ class RedisStringCacheRegionTest { } return ByteBuffer.allocate(content.length + digest.length).put(content).put(digest).array(); } + + private static byte[] envelopeWithVersion(byte version) { + return envelopeWithVersionAndType(version, (byte) 1); + } + + private static byte[] envelopeWithVersionAndType(byte version, byte type) { + byte[] content = + ByteBuffer.allocate(4 + 1 + 1).putInt(0x43414348).put(version).put(type).array(); + byte[] digest; + try { + digest = java.security.MessageDigest.getInstance("SHA-256").digest(content); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + return ByteBuffer.allocate(content.length + digest.length).put(content).put(digest).array(); + } + + private static final class MutableClock extends Clock { + + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + if (!ZoneOffset.UTC.equals(zone)) { + throw new IllegalArgumentException("test clock supports UTC only"); + } + return this; + } + + @Override + public Instant instant() { + return instant; + } + } } diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutorTest.java new file mode 100644 index 0000000..8bbaf5b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisStructuredProgramExecutorTest.java @@ -0,0 +1,239 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RedisStructuredProgramExecutorTest { + + private static final List VALID_REPLY = + reply( + "ALLOWED", + "ALLOWED", + "1700000000000", + "1700000000000", + "100", + "99", + "0", + "1700000001000"); + + @Test + void keepsTheV1ReplyAndInvocationContractReadableDuringRollingDeployment() { + FakeCommands commands = new FakeCommands(); + commands.reply = + reply("ALLOWED", "1700000000000", "1700000000000", "100", "99", "0", "1700000001000"); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisStructuredProgramExecutor executor = new RedisStructuredProgramExecutor(catalog, commands); + + RedisRateProgramReply parsed = + executor.execute( + RedisProgramTestInvocations.structured( + catalog, + RedisProgramId.RATE_FIXED_WINDOW, + List.of("rate-state".getBytes(US_ASCII)), + List.of("1", "revision-1", "100", "1", "1000", "5000", "250").stream() + .map(value -> value.getBytes(US_ASCII)) + .toList())); + + assertThat(parsed.status()).isEqualTo(RedisRateProgramStatus.ALLOWED); + assertThat(parsed.decision()).isEqualTo(RedisRateProgramDecision.ALLOWED); + assertThat(parsed.remaining()).isEqualTo(99); + } + + @Test + void recoversNoScriptWithOneExactScriptLoadAndOneEvalShaRetry() { + FakeCommands commands = new FakeCommands(); + commands.noScript = true; + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisStructuredProgramExecutor executor = new RedisStructuredProgramExecutor(catalog, commands); + + RedisRateProgramReply reply = executor.execute(invocation(catalog)); + + assertThat(reply.status()).isEqualTo(RedisRateProgramStatus.ALLOWED); + assertThat(reply.decision()).isEqualTo(RedisRateProgramDecision.ALLOWED); + assertThat(reply.remaining()).isEqualTo(99); + assertThat(commands.evalShaMultiCalls).hasValue(2); + assertThat(commands.scriptLoadCalls).hasValue(1); + assertThat(commands.commandTrace).containsExactly("EVALSHA", "SCRIPT_LOAD", "EVALSHA"); + assertThat(commands.lastLoadedScript) + .containsExactly(catalog.descriptor(RedisProgramId.RATE_FIXED_WINDOW_V2).scriptBytes()); + } + + @Test + void doesNotFallbackForAnOrdinaryExecutionFailure() { + FakeCommands commands = new FakeCommands(); + commands.executionFailure = new IllegalStateException("transport failed"); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisStructuredProgramExecutor executor = new RedisStructuredProgramExecutor(catalog, commands); + + assertThatThrownBy(() -> executor.execute(invocation(catalog))) + .isSameAs(commands.executionFailure); + assertThat(commands.scriptLoadCalls).hasValue(0); + } + + @Test + void rejectsForeignDescriptorsAndInvocationShapeBeforeCallingRedis() { + FakeCommands commands = new FakeCommands(); + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + RedisStructuredProgramExecutor executor = new RedisStructuredProgramExecutor(catalog, commands); + RedisProgramDescriptor foreign = + RedisProgramCatalog.rateLimit().descriptor(RedisProgramId.RATE_FIXED_WINDOW_V2); + + assertThatThrownBy( + () -> + executor.execute( + RedisProgramTestInvocations.structured( + RedisProgramCatalog.rateLimit(), + foreign.id(), + rateKeys(), + fixedArguments()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not owned"); + assertThatThrownBy( + () -> + executor.execute( + RedisProgramTestInvocations.structured( + catalog, + RedisProgramId.RATE_FIXED_WINDOW_V2, + rateKeys(), + List.of("1".getBytes(US_ASCII))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("signature"); + assertThat(commands.evalShaMultiCalls).hasValue(0); + } + + @Test + void requiresExactlyEightBoundedFieldsAndDeclaredStatusAndDecision() { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + FakeCommands commands = new FakeCommands(); + RedisStructuredProgramExecutor executor = new RedisStructuredProgramExecutor(catalog, commands); + RedisProgramDescriptor descriptor = catalog.descriptor(RedisProgramId.RATE_FIXED_WINDOW_V2); + + commands.reply = VALID_REPLY.subList(0, 7); + assertCompatibilityFailure(catalog, executor, descriptor); + + commands.reply = new ArrayList<>(VALID_REPLY); + commands.reply.set(0, "UNDECLARED".getBytes(US_ASCII)); + assertCompatibilityFailure(catalog, executor, descriptor); + + commands.reply = new ArrayList<>(VALID_REPLY); + commands.reply.set(1, "UNKNOWN".getBytes(US_ASCII)); + assertCompatibilityFailure(catalog, executor, descriptor); + + commands.reply = new ArrayList<>(VALID_REPLY); + commands.reply.set(2, new byte[descriptor.maximumReplyFieldBytes() + 1]); + assertCompatibilityFailure(catalog, executor, descriptor); + } + + @Test + void rejectsNonCanonicalAsciiIntegersAndValuesOutsideLuaExactRange() { + RedisProgramCatalog catalog = RedisProgramCatalog.rateLimit(); + FakeCommands commands = new FakeCommands(); + RedisStructuredProgramExecutor executor = new RedisStructuredProgramExecutor(catalog, commands); + RedisProgramDescriptor descriptor = catalog.descriptor(RedisProgramId.RATE_FIXED_WINDOW_V2); + + for (String invalid : List.of("", "-1", "+1", "01", "1.0", "9", "9007199254740992")) { + commands.reply = new ArrayList<>(VALID_REPLY); + commands.reply.set(2, invalid.getBytes(US_ASCII)); + assertCompatibilityFailure(catalog, executor, descriptor); + } + } + + private static void assertCompatibilityFailure( + RedisProgramCatalog catalog, + RedisStructuredProgramExecutor executor, + RedisProgramDescriptor descriptor) { + assertThatThrownBy( + () -> + executor.execute( + RedisProgramTestInvocations.structured( + catalog, descriptor.id(), rateKeys(), fixedArguments()))) + .isInstanceOf(RedisProgramCompatibilityException.class); + } + + private static RedisCatalogProgramInvocation invocation(RedisProgramCatalog catalog) { + return RedisProgramTestInvocations.structured( + catalog, RedisProgramId.RATE_FIXED_WINDOW_V2, rateKeys(), fixedArguments()); + } + + private static List fixedArguments() { + return List.of( + "2", + "revision-1", + "100", + "1", + "1000", + "5000", + "250", + "ev1:AAAAAAAAAAAAAAAAAAAAAA", + "5000", + "256", + "65536") + .stream() + .map(value -> value.getBytes(US_ASCII)) + .toList(); + } + + private static List rateKeys() { + return List.of("rate-state", "rate-dedup", "rate-dedup-order").stream() + .map(value -> value.getBytes(US_ASCII)) + .toList(); + } + + private static List reply(String... fields) { + return java.util.Arrays.stream(fields).map(field -> field.getBytes(US_ASCII)).toList(); + } + + private static final class FakeCommands implements RedisBinaryCommands { + + private final AtomicInteger evalShaMultiCalls = new AtomicInteger(); + private final AtomicInteger scriptLoadCalls = new AtomicInteger(); + private final java.util.ArrayList commandTrace = new java.util.ArrayList<>(); + private boolean noScript; + private RuntimeException executionFailure; + private List reply = VALID_REPLY; + private byte[] lastLoadedScript; + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + scriptLoadCalls.incrementAndGet(); + commandTrace.add("SCRIPT_LOAD"); + lastLoadedScript = RedisCatalogProgramInvocation.WireCodec.exactScript(invocation); + return invocation.sha1(); + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + evalShaMultiCalls.incrementAndGet(); + commandTrace.add("EVALSHA"); + if (executionFailure != null) { + throw executionFailure; + } + if (noScript) { + noScript = false; + throw new RedisNoScriptException(); + } + return RedisCatalogProgramReply.multi(reply); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeCloseTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeCloseTest.java new file mode 100644 index 0000000..8997e5c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeCloseTest.java @@ -0,0 +1,219 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisCredentials; +import io.lettuce.core.RedisCredentialsProvider; +import io.lettuce.core.RedisFuture; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulConnection; +import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands; +import io.lettuce.core.pubsub.StatefulRedisPubSubConnection; +import io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands; +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import javax.security.auth.Destroyable; +import org.junit.jupiter.api.Test; + +class RedisTopologyCommandRuntimeCloseTest { + + private static final RedisClientRuntimeSettings SETTINGS = + new RedisClientRuntimeSettings( + "sentinel-close", + Duration.ofMillis(200), + Duration.ofMillis(300), + Duration.ofMillis(200), + Duration.ofSeconds(1), + Duration.ofMillis(500), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void pubSubCloseFailureCannotBypassMainClientAndCredentialCleanup() { + AtomicInteger pubSubCloseCalls = new AtomicInteger(); + AtomicInteger mainConnectionCloseCalls = new AtomicInteger(); + AtomicInteger clientShutdownCalls = new AtomicInteger(); + CountingCredentialsProvider credentials = new CountingCredentialsProvider(); + RedisLettuceUris.SentinelData credentialOwner = + new RedisLettuceUris.SentinelData( + RedisURI.builder() + .withHost("redis-primary.internal") + .withPort(6379) + .withAuthentication(credentials) + .build()); + StatefulRedisPubSubConnection pubSubConnection = + pubSubConnection(pubSubCloseCalls); + RedisTopologyCommandRuntime runtime = + new RedisTopologyCommandRuntime( + "sentinel-main", + client(clientShutdownCalls), + mainConnection(mainConnectionCloseCalls), + commands(), + RedisRouteIdentity.sentinel( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)), + credentialOwner, + SETTINGS, + () -> pubSubConnection); + runtime.subscribe( + "cache-invalidation".getBytes(java.nio.charset.StandardCharsets.US_ASCII), + new RedisInvalidationTransport.Listener() { + @Override + public void onMessage(byte[] wireMessage) {} + + @Override + public void onDisconnected() {} + }); + + assertThatThrownBy(runtime::close) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Redis topology runtime close failed") + .hasNoCause() + .hasMessageNotContaining("redis-primary.internal") + .hasMessageNotContaining("sentinel-main") + .hasMessageNotContaining("secret://") + .hasMessageNotContaining("raw"); + + assertThat(pubSubCloseCalls).hasValue(1); + assertThat(mainConnectionCloseCalls).hasValue(1); + assertThat(clientShutdownCalls).hasValue(1); + assertThat(credentials.destroyCalls).hasValue(1); + + assertThatCode(runtime::close).doesNotThrowAnyException(); + assertThat(pubSubCloseCalls).hasValue(1); + assertThat(mainConnectionCloseCalls).hasValue(1); + assertThat(clientShutdownCalls).hasValue(1); + assertThat(credentials.destroyCalls).hasValue(1); + } + + private static RedisClient client(AtomicInteger shutdownCalls) { + return new RedisClient() { + @Override + public void shutdown(Duration quietPeriod, Duration timeout) { + shutdownCalls.incrementAndGet(); + throw new IllegalStateException("raw client shutdown secret://redis/data/password"); + } + }; + } + + @SuppressWarnings("unchecked") + private static StatefulConnection mainConnection(AtomicInteger closeCalls) { + return (StatefulConnection) + Proxy.newProxyInstance( + RedisTopologyCommandRuntimeCloseTest.class.getClassLoader(), + new Class[] {StatefulConnection.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("close")) { + closeCalls.incrementAndGet(); + throw new IllegalStateException("raw main close redis-primary.internal"); + } + throw new UnsupportedOperationException(method.getName()); + }); + } + + @SuppressWarnings("unchecked") + private static RedisClusterAsyncCommands commands() { + return (RedisClusterAsyncCommands) + Proxy.newProxyInstance( + RedisTopologyCommandRuntimeCloseTest.class.getClassLoader(), + new Class[] {RedisClusterAsyncCommands.class}, + (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + }); + } + + @SuppressWarnings("unchecked") + private static StatefulRedisPubSubConnection pubSubConnection( + AtomicInteger closeCalls) { + RedisPubSubAsyncCommands async = + (RedisPubSubAsyncCommands) + Proxy.newProxyInstance( + RedisTopologyCommandRuntimeCloseTest.class.getClassLoader(), + new Class[] {RedisPubSubAsyncCommands.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("subscribe") + || method.getName().equals("unsubscribe")) { + return completed(null); + } + throw new UnsupportedOperationException(method.getName()); + }); + return (StatefulRedisPubSubConnection) + Proxy.newProxyInstance( + RedisTopologyCommandRuntimeCloseTest.class.getClassLoader(), + new Class[] {StatefulRedisPubSubConnection.class}, + (proxy, method, arguments) -> { + return switch (method.getName()) { + case "addListener" -> null; + case "async" -> async; + case "close" -> { + closeCalls.incrementAndGet(); + throw new IllegalStateException("raw Pub/Sub close secret=data-secret"); + } + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + } + + private static RedisFuture completed(T value) { + FakeRedisFuture future = new FakeRedisFuture<>(); + future.complete(value); + return future; + } + + private static final class FakeRedisFuture extends CompletableFuture + implements RedisFuture { + + @Override + public String getError() { + return null; + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + try { + get(timeout, unit); + return true; + } catch (TimeoutException exception) { + return false; + } catch (java.util.concurrent.ExecutionException exception) { + return true; + } + } + } + + private static final class CountingCredentialsProvider + implements RedisCredentialsProvider, + RedisCredentialsProvider.ImmediateRedisCredentialsProvider, + Destroyable { + + private final AtomicInteger destroyCalls = new AtomicInteger(); + + @Override + public reactor.core.publisher.Mono resolveCredentials() { + return reactor.core.publisher.Mono.error(new UnsupportedOperationException()); + } + + @Override + public RedisCredentials resolveCredentialsNow() { + throw new UnsupportedOperationException(); + } + + @Override + public void destroy() { + destroyCalls.incrementAndGet(); + } + + @Override + public boolean isDestroyed() { + return destroyCalls.get() > 0; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSentinelBootstrapTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSentinelBootstrapTest.java new file mode 100644 index 0000000..f050752 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSentinelBootstrapTest.java @@ -0,0 +1,272 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import io.lettuce.core.RedisClient; +import java.lang.reflect.Proxy; +import java.net.ConnectException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RedisTopologyCommandRuntimeSentinelBootstrapTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + private static final RedisClientRuntimeSettings SETTINGS = + new RedisClientRuntimeSettings( + "sentinel-runtime", + Duration.ofMillis(200), + Duration.ofMillis(300), + Duration.ofMillis(200), + Duration.ofSeconds(1), + Duration.ofMillis(500), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void sentinelBootstrapDiscoversOnceThenConnectsTheExactSameApprovedRoute() { + CapturingConnector connector = + new CapturingConnector( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + + RedisRoutableCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + sentinel(), + SETTINGS, + 16_384, + ignored -> { + throw new AssertionError("injected connector owns credential resolution"); + }, + ignored -> { + throw new AssertionError("injected connector owns trust resolution"); + }, + Clock.fixed(NOW, ZoneOffset.UTC), + connector); + + assertThat(connector.discoveryCalls).hasValue(1); + assertThat(connector.connectCalls).hasValue(1); + assertThat(connector.connectedRoute).isSameAs(connector.discoveredRoute); + assertThat(connector.connectedRoute.endpoint().host()).isEqualTo("redis-primary.internal"); + assertThat(connector.connectedRoute.endpoint().port()).isEqualTo(6379); + assertThat(runtime.routeIdentity()).isEqualTo(connector.discoveredRoute.identity()); + assertThat(runtime.deploymentId()).isEqualTo("sentinel-main"); + runtime.close(); + } + + @Test + void sentinelBootstrapIdentityIsStablePerApprovedEndpointAndAlwaysRedacted() { + RedisRouteIdentity first = + connectForIdentity( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + RedisRouteIdentity same = + connectForIdentity( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-primary.internal", 6379)); + RedisRouteIdentity different = + connectForIdentity( + new RedisSentinelMasterDiscovery.DataEndpoint("redis-replica-a.internal", 6379)); + + assertThat(first).isEqualTo(same).isNotEqualTo(different); + assertThat(first.toString()) + .doesNotContain( + "redis-primary.internal", + "redis-replica-a.internal", + "6379", + "sentinel-main", + "secret://"); + } + + @Test + void malformedDiscoveryFailsBeforeExactDataConnection() { + RedisSentinelRuntimeConnector connector = + new RedisSentinelRuntimeConnector() { + @Override + public RedisSentinelDiscoveredRoute discover( + RedisDeploymentSettings.Sentinel deployment) { + throw RedisSentinelMasterDiscovery.failure(); + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, + RedisSentinelDiscoveredRoute discoveredRoute) { + throw new AssertionError("failed discovery must not open data"); + } + }; + + assertThatThrownBy( + () -> + RedisTopologyCommandRuntime.connect( + sentinel(), + SETTINGS, + 16_384, + ignored -> { + throw new AssertionError("injected connector owns credential resolution"); + }, + ignored -> { + throw new AssertionError("injected connector owns trust resolution"); + }, + Clock.fixed(NOW, ZoneOffset.UTC), + connector)) + .isInstanceOf(RedisSentinelMasterDiscovery.DiscoveryFailedException.class) + .hasMessage("Redis Sentinel master discovery failed"); + } + + @Test + void cleanupFailuresCannotOverrideTheOriginalSanitizedConnectionFailure() { + AtomicInteger connectionCloseCalls = new AtomicInteger(); + AtomicInteger shutdownCalls = new AtomicInteger(); + io.lettuce.core.api.StatefulConnection connection = + (io.lettuce.core.api.StatefulConnection) + Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] {io.lettuce.core.api.StatefulConnection.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("closeAsync")) { + connectionCloseCalls.incrementAndGet(); + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("raw-close secret=sentinel-secret")); + } + throw new UnsupportedOperationException(method.getName()); + }); + RedisClient client = + new RedisClient() { + @Override + public java.util.concurrent.CompletableFuture shutdownAsync( + long quietPeriod, long timeout, java.util.concurrent.TimeUnit unit) { + shutdownCalls.incrementAndGet(); + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("raw-shutdown secret=data-secret")); + } + }; + + assertThatCode( + () -> + RedisTopologyCommandRuntime.closeFailed( + client, connection, SETTINGS.shutdownTimeout())) + .doesNotThrowAnyException(); + assertThat( + RedisTopologyCommandRuntime.sanitizeConnectionFailure( + new ConnectException("redis-primary.internal secret=data-secret"))) + .isInstanceOf(RedisTemporaryConnectionException.class) + .hasMessage("Redis topology is temporarily unavailable"); + assertThat(connectionCloseCalls).hasValue(1); + assertThat(shutdownCalls).hasValue(1); + } + + private static RedisDeploymentSettings.Sentinel sentinel() { + return new RedisDeploymentSettings.Sentinel( + "sentinel-main", + 2, + "cache-master", + List.of( + new RedisDeploymentSettings.Endpoint("sentinel-a.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-b.internal", 26379), + new RedisDeploymentSettings.Endpoint("sentinel-c.internal", 26379)), + List.of( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)), + new RedisDeploymentSettings.Authentication("sentinel", "secret://redis/sentinel/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/sentinel/ca"), + new RedisDeploymentSettings.Authentication("data", "secret://redis/data/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/data/ca")); + } + + private static RedisRouteIdentity connectForIdentity( + RedisSentinelMasterDiscovery.DataEndpoint endpoint) { + CapturingConnector connector = new CapturingConnector(endpoint); + RedisRoutableCommandRuntime runtime = + RedisTopologyCommandRuntime.connect( + sentinel(), + SETTINGS, + 16_384, + ignored -> { + throw new AssertionError("injected connector owns credential resolution"); + }, + ignored -> { + throw new AssertionError("injected connector owns trust resolution"); + }, + Clock.fixed(NOW, ZoneOffset.UTC), + connector); + try (runtime) { + return runtime.routeIdentity(); + } + } + + private static final class CapturingConnector implements RedisSentinelRuntimeConnector { + + private final RedisSentinelMasterDiscovery.DataEndpoint endpoint; + private final AtomicInteger discoveryCalls = new AtomicInteger(); + private final AtomicInteger connectCalls = new AtomicInteger(); + private RedisSentinelDiscoveredRoute discoveredRoute; + private RedisSentinelDiscoveredRoute connectedRoute; + + private CapturingConnector(RedisSentinelMasterDiscovery.DataEndpoint endpoint) { + this.endpoint = endpoint; + } + + @Override + public RedisSentinelDiscoveredRoute discover(RedisDeploymentSettings.Sentinel deployment) { + discoveryCalls.incrementAndGet(); + discoveredRoute = RedisSentinelDiscoveredRoute.fromQuorum(endpoint); + return discoveredRoute; + } + + @Override + public RedisRoutableCommandRuntime connect( + RedisDeploymentSettings.Sentinel deployment, RedisSentinelDiscoveredRoute discoveredRoute) { + connectCalls.incrementAndGet(); + connectedRoute = discoveredRoute; + return new RouteRuntime(deployment.deploymentId(), discoveredRoute.identity()); + } + } + + private static final class RouteRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final RedisRouteIdentity routeIdentity; + + private RouteRuntime(String deploymentId, RedisRouteIdentity routeIdentity) { + this.deploymentId = deploymentId; + this.routeIdentity = routeIdentity; + } + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public RedisRouteIdentity routeIdentity() { + return routeIdentity; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + return null; + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) {} + + @Override + public long delete(RedisPhysicalKey key) { + return 0; + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSurfaceTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSurfaceTest.java new file mode 100644 index 0000000..345c08e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntimeSurfaceTest.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisRotatableRuntime; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class RedisTopologyCommandRuntimeSurfaceTest { + + @Test + void remainsPackagePrivateAndExposesOnlySemanticAdapterCommandContracts() { + assertThat(Modifier.isPublic(RedisTopologyCommandRuntime.class.getModifiers())).isFalse(); + assertThat(RedisBinaryCommands.class).isAssignableFrom(RedisTopologyCommandRuntime.class); + assertThat(RedisStructuredCommands.class).isAssignableFrom(RedisTopologyCommandRuntime.class); + assertThat(RedisRotatableRuntime.class).isAssignableFrom(RedisTopologyCommandRuntime.class); + assertThat( + Arrays.stream(RedisTopologyCommandRuntime.class.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers())) + .flatMap( + method -> + java.util.stream.Stream.concat( + java.util.stream.Stream.of(method.getReturnType()), + Arrays.stream(method.getParameterTypes()))) + .map(Class::getName)) + .noneMatch(type -> type.startsWith("io.lettuce.")); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyConnectionFailureClassifierTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyConnectionFailureClassifierTest.java new file mode 100644 index 0000000..645c1e4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyConnectionFailureClassifierTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.RedisCommandTimeoutException; +import io.lettuce.core.RedisConnectionException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.channels.ClosedChannelException; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLHandshakeException; +import org.junit.jupiter.api.Test; + +class RedisTopologyConnectionFailureClassifierTest { + + @Test + void onlyNetworkReachabilityFailuresBecomeRetryable() { + assertTemporary(new RedisConnectionException("outer", new ConnectException("refused"))); + assertTemporary(new RedisConnectionException("outer", new UnknownHostException("raw host"))); + assertTemporary( + new RedisConnectionException("outer", new SocketTimeoutException("raw timeout"))); + assertTemporary(new RedisConnectionException("outer", new TimeoutException("raw deadline"))); + assertTemporary( + new RedisConnectionException( + "outer", new RedisCommandTimeoutException("raw command timeout"))); + assertTemporary(new RedisConnectionException("outer", new ClosedChannelException())); + } + + @Test + void authenticationTlsAndUnknownFailuresRemainPermanentAndSanitized() { + assertPermanent( + new RedisConnectionException( + "outer", + new RedisCommandExecutionException("WRONGPASS invalid username-password pair"))); + assertPermanent( + new RedisConnectionException( + "outer", new RedisCommandExecutionException("NOAUTH authentication required"))); + assertPermanent( + new RedisConnectionException( + "outer", new RedisCommandExecutionException("NOPERM command denied"))); + assertPermanent( + new RedisConnectionException( + "outer", new SSLHandshakeException("certificate subject mismatch"))); + assertPermanent(new RedisConnectionException("opaque protocol failure")); + assertPermanent(new IllegalArgumentException("unknown material failure")); + } + + @Test + void cyclicCauseChainIsBoundedAndFailsClosed() { + assertPermanent(new CyclicFailure("cycle raw detail")); + } + + @Test + void onlyTopologyTransportFailuresRequestSentinelRediscovery() { + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisConnectionException("outer", new ConnectException("refused")), true) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL); + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisCommandTimeoutException("deadline"), false) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.REDISCOVER_SENTINEL); + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisCommandExecutionException("ERR invalid argument"), true) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.NONE); + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisCommandExecutionException("NOPERM command denied"), true) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.NONE); + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisConnectionException( + "outer", + new RedisCommandExecutionException( + "WRONGPASS invalid username-password pair")), + true) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.NONE); + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisConnectionException( + "outer", new SSLHandshakeException("certificate subject mismatch")), + true) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.NONE); + assertThat( + RedisTopologyCommandRuntime.classifyCommandFailure( + new RedisConnectionException( + "outer", new RedisCommandExecutionException("ERR invalid argument")), + true) + .recoveryHint()) + .isEqualTo(RedisCommandFailureException.RecoveryHint.NONE); + } + + private static void assertTemporary(Throwable failure) { + RuntimeException sanitized = RedisTopologyCommandRuntime.sanitizeConnectionFailure(failure); + assertThat(sanitized) + .isInstanceOf(RedisTemporaryConnectionException.class) + .hasMessageNotContaining("outer"); + assertThat(sanitized.getCause()).isNull(); + } + + private static void assertPermanent(Throwable failure) { + RuntimeException sanitized = RedisTopologyCommandRuntime.sanitizeConnectionFailure(failure); + assertThat(sanitized) + .isInstanceOf(IllegalStateException.class) + .isNotInstanceOf(RedisTemporaryConnectionException.class); + assertThat(sanitized.getMessage()).doesNotContain(failure.getMessage()); + assertThat(sanitized.getCause()).isNull(); + } + + private static final class CyclicFailure extends RuntimeException { + + private CyclicFailure(String message) { + super(message, null); + } + + @Override + public synchronized Throwable getCause() { + return this; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepositoryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepositoryTest.java new file mode 100644 index 0000000..5fedf03 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepositoryTest.java @@ -0,0 +1,273 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RedisVersionedSessionRepositoryTest { + + private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + + @Test + void podACreateAndPodBReadTouchLogoutAndRejectAStaleSave() { + InMemoryVersionedSessionStore store = new InMemoryVersionedSessionStore(); + RedisSessionEnvelopeCodec codec = new RedisSessionEnvelopeCodec(4096, 16, 1024); + MutableClock clock = new MutableClock(NOW); + RedisVersionedSessionRepository podA = repository(store, codec, clock); + RedisVersionedSessionRepository podB = repository(store, codec, clock); + + RedisVersionedSession session = podA.createSession(); + session.setAttribute("subject", "user-42"); + podA.save(session); + RedisVersionedSession staleRequest = podA.findById(session.getId()); + + clock.advance(Duration.ofMinutes(2)); + RedisVersionedSession readByPodB = podB.findById(session.getId()); + assertThat(readByPodB.getAttribute("subject")).isEqualTo("user-42"); + assertThat(store.touchCount).isEqualTo(1); + + podB.deleteById(session.getId()); + staleRequest.setAttribute("subject", "must-not-resurrect"); + assertThatThrownBy(() -> podA.save(staleRequest)) + .isInstanceOf(RedisSessionConflictException.class); + assertThat(podB.findById(session.getId())).isNull(); + } + + @Test + void rotationRejectsTheOldIdentifierAndPreservesTheBoundedAbsoluteLifetime() { + InMemoryVersionedSessionStore store = new InMemoryVersionedSessionStore(); + RedisSessionEnvelopeCodec codec = new RedisSessionEnvelopeCodec(4096, 16, 1024); + MutableClock clock = new MutableClock(NOW); + RedisVersionedSessionRepository repository = repository(store, codec, clock); + RedisVersionedSession session = repository.createSession(); + session.setAttribute("subject", "user-42"); + repository.save(session); + String oldId = session.getId(); + + String newId = session.changeSessionId(); + repository.save(session); + + assertThat(newId).isNotEqualTo(oldId); + assertThat(repository.findById(oldId)).isNull(); + assertThat(repository.findById(newId).getAttribute("subject")).isEqualTo("user-42"); + + clock.advance(Duration.ofHours(8).plusMillis(1)); + assertThat(repository.findById(newId)).isNull(); + } + + @Test + void corruptPayloadAndStoreOutageFailClosed() { + InMemoryVersionedSessionStore store = new InMemoryVersionedSessionStore(); + RedisSessionEnvelopeCodec codec = new RedisSessionEnvelopeCodec(4096, 16, 1024); + RedisVersionedSessionRepository repository = repository(store, codec, new MutableClock(NOW)); + RedisVersionedSession session = repository.createSession(); + repository.save(session); + store.live.get(session.getId()).payload()[0] ^= 1; + + assertThat(repository.findById(session.getId())).isNull(); + assertThat(store.tombstones).containsKey(session.getId()); + + store.unavailable = true; + assertThatThrownBy(() -> repository.findById("0123456789abcdef")) + .isInstanceOf(RedisSessionUnavailableException.class); + } + + private static RedisVersionedSessionRepository repository( + VersionedRedisSessionStore store, RedisSessionEnvelopeCodec codec, Clock clock) { + return new RedisVersionedSessionRepository( + store, + codec, + clock, + Duration.ofMinutes(30), + Duration.ofHours(8), + Duration.ofMinutes(1), + Duration.ofMinutes(5)); + } + + private static final class InMemoryVersionedSessionStore implements VersionedRedisSessionStore { + + private final Map live = new LinkedHashMap<>(); + private final Map tombstones = new LinkedHashMap<>(); + private int touchCount; + private boolean unavailable; + private long operations; + + @Override + public SessionMutationAttempt newMutationAttempt() { + return new SessionMutationAttempt("operation-" + ++operations); + } + + @Override + public SessionCreateOutcome create(SessionCreateCommand command) { + available(); + if (tombstones.containsKey(command.sessionId())) { + return SessionCreateOutcome.TOMBSTONED; + } + live.put( + command.sessionId(), + new StoredSession( + command.payload().clone(), + command.newRevision(), + command.absoluteExpiresAt(), + command.lastAccessedAt())); + return SessionCreateOutcome.CREATED; + } + + @Override + public SessionInspectionOutcome inspect(SessionInspectionCommand command) { + available(); + if (tombstones.containsKey(command.sessionId())) { + return new SessionInspectionOutcome.Tombstoned(); + } + StoredSession stored = live.get(command.sessionId()); + if (stored == null) { + return new SessionInspectionOutcome.Absent(); + } + if (!command.now().isBefore(stored.absoluteExpiresAt())) { + live.remove(command.sessionId()); + return new SessionInspectionOutcome.AbsoluteExpired(); + } + return new SessionInspectionOutcome.Live( + stored.payload().clone(), + stored.revision(), + stored.absoluteExpiresAt(), + stored.lastAccessedAt()); + } + + @Override + public SessionSaveOutcome saveIfLive(SessionSaveCommand command) { + available(); + if (tombstones.containsKey(command.sessionId())) { + return SessionSaveOutcome.TOMBSTONED; + } + StoredSession stored = live.get(command.sessionId()); + if (stored == null) { + return SessionSaveOutcome.ABSENT; + } + if (stored.revision() != command.expectedRevision()) { + return SessionSaveOutcome.STALE_REVISION; + } + live.put( + command.sessionId(), + new StoredSession( + command.payload().clone(), + command.newRevision(), + command.absoluteExpiresAt(), + command.lastAccessedAt())); + return SessionSaveOutcome.SAVED; + } + + @Override + public SessionTouchOutcome touchIfLive(SessionTouchCommand command) { + available(); + if (tombstones.containsKey(command.sessionId())) { + return SessionTouchOutcome.TOMBSTONED; + } + StoredSession stored = live.get(command.sessionId()); + if (stored == null) { + return SessionTouchOutcome.ABSENT; + } + if (stored.revision() != command.expectedRevision()) { + return SessionTouchOutcome.STALE_REVISION; + } + touchCount++; + live.put( + command.sessionId(), + new StoredSession( + stored.payload(), stored.revision(), stored.absoluteExpiresAt(), command.now())); + return SessionTouchOutcome.TOUCHED; + } + + @Override + public SessionRevokeOutcome tombstoneAndDelete(SessionRevokeCommand command) { + available(); + StoredSession stored = live.get(command.sessionId()); + if (stored != null + && command.expectedRevision() != 0 + && stored.revision() != command.expectedRevision()) { + return SessionRevokeOutcome.STALE_REVISION; + } + live.remove(command.sessionId()); + tombstones.put(command.sessionId(), command.attempt().operationId()); + return stored == null + ? SessionRevokeOutcome.TOMBSTONED_ABSENT + : SessionRevokeOutcome.REVOKED_AND_DELETED; + } + + @Override + public SessionRotateOutcome rotate(SessionRotateCommand command) { + available(); + if (tombstones.containsKey(command.oldSessionId())) { + return SessionRotateOutcome.OLD_TOMBSTONED; + } + if (live.containsKey(command.newSessionId())) { + return SessionRotateOutcome.NEW_ID_CONFLICT; + } + StoredSession old = live.get(command.oldSessionId()); + if (old == null) { + return SessionRotateOutcome.OLD_ABSENT; + } + if (old.revision() != command.expectedRevision()) { + return SessionRotateOutcome.STALE_REVISION; + } + live.remove(command.oldSessionId()); + tombstones.put(command.oldSessionId(), command.attempt().operationId()); + live.put( + command.newSessionId(), + new StoredSession( + command.payload().clone(), + command.newRevision(), + command.absoluteExpiresAt(), + command.lastAccessedAt())); + return SessionRotateOutcome.ROTATED; + } + + private void available() { + if (unavailable) { + throw new RedisCommandFailureException( + RedisCommandFailureException.Kind.UNAVAILABLE, + RedisCommandFailureException.Certainty.NOT_APPLIED, + "test unavailable", + null); + } + } + } + + private record StoredSession( + byte[] payload, long revision, Instant absoluteExpiresAt, Instant lastAccessedAt) {} + + private static final class MutableClock extends Clock { + + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java new file mode 100644 index 0000000..534622f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisDeploymentSettingsFactoryTest.java @@ -0,0 +1,598 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RedisDeploymentSettingsFactoryTest { + + private final RedisDeploymentSettingsFactory factory = new RedisDeploymentSettingsFactory(); + + @Test + void compilesExactlyOneStandaloneTopology() { + RedisProviderSettings properties = + provider( + Map.of( + "cache-main", + deployment( + RedisProviderSettings.Topology.STANDALONE, + new RedisProviderSettings.StandaloneProperties( + List.of(endpoint("cache.internal", 6379))), + null, + null, + 2, + authentication("cache-runtime", "secret://redis/cache/password"), + tls("secret://redis/cache/ca"))), + Map.of(RedisRole.CACHE, new RedisRoleBinding("cache-main", false, "allkeys-lfu"))); + + Map active = factory.compileActive(properties); + + assertThat(active).containsOnlyKeys(RedisRole.CACHE); + assertThat(active.get(RedisRole.CACHE)) + .isEqualTo( + new RedisDeploymentSettings.Standalone( + "cache-main", + 2, + List.of(new RedisDeploymentSettings.Endpoint("cache.internal", 6379)), + new RedisDeploymentSettings.Authentication( + "cache-runtime", "secret://redis/cache/password"), + new RedisDeploymentSettings.Tls(true, true, "secret://redis/cache/ca"))); + } + + @Test + void rejectsMissingMismatchedOrMultipleTopologyBodies() { + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "cache-main", + deployment( + RedisProviderSettings.Topology.STANDALONE, + null, + null, + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one") + .hasMessageContaining("cache-main"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "cache-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + new RedisProviderSettings.StandaloneProperties( + List.of(endpoint("cache.internal", 6379))), + sentinelProperties(), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "cache-main", + deployment( + RedisProviderSettings.Topology.CLUSTER, + new RedisProviderSettings.StandaloneProperties( + List.of(endpoint("cache.internal", 6379))), + null, + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not match"); + } + + @Test + void rejectsEmptyBlankOrDuplicateEndpoints() { + assertThatThrownBy( + () -> + factory.compileRegistered( + provider(Map.of("cache-main", standaloneDeployment(List.of())), Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("endpoint"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of("cache-main", standaloneDeployment(List.of(endpoint(" ", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("host"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "cluster-main", + clusterDeployment( + 0, + List.of( + endpoint("REDIS-A.INTERNAL", 6379), + endpoint("redis-a.internal", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + } + + @Test + void clusterRequiresDatabaseZero() { + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "cluster-main", + clusterDeployment( + 1, + List.of( + endpoint("redis-a.internal", 6379), + endpoint("redis-b.internal", 6379), + endpoint("redis-c.internal", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("database 0"); + } + + @Test + void sentinelRequiresMasterThreeIndependentEndpointsAndSeparateChannels() { + RedisProviderSettings.DeploymentProperties valid = + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + sentinelProperties(), + null, + 0, + dataAuthentication(), + dataTls()); + + RedisDeploymentSettings.Sentinel compiled = + (RedisDeploymentSettings.Sentinel) + factory + .compileRegistered(provider(Map.of("coord-main", valid), Map.of())) + .get("coord-main"); + + assertThat(compiled.masterName()).isEqualTo("ca-coordination"); + assertThat(compiled.sentinelEndpoints()).hasSize(3); + assertThat(compiled.dataEndpoints()) + .containsExactly( + new RedisDeploymentSettings.Endpoint("redis-primary.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-a.internal", 6379), + new RedisDeploymentSettings.Endpoint("redis-replica-b.internal", 6379)); + assertThat(compiled.sentinelAuthentication()).isNotEqualTo(compiled.dataAuthentication()); + assertThat(compiled.sentinelTls()).isNotSameAs(compiled.dataTls()); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + new RedisProviderSettings.SentinelProperties( + "ca-coordination", + List.of( + endpoint("sentinel-a.internal", 26379), + endpoint("sentinel-b.internal", 26379)), + dataEndpoints(), + authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca")), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 3"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + new RedisProviderSettings.SentinelProperties( + " ", + sentinelEndpoints(), + dataEndpoints(), + authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca")), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("master"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + new RedisProviderSettings.SentinelProperties( + "ca-coordination", + sentinelEndpoints(), + dataEndpoints(), + dataAuthentication(), + dataTls()), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("separate"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + new RedisProviderSettings.SentinelProperties( + "ca-coordination", + sentinelEndpoints(), + dataEndpoints(), + authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + dataTls()), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TLS") + .hasMessageContaining("separate"); + } + + @Test + void sentinelRequiresThreeUniqueValidDataEndpointsBeforeResolvingMaterial() { + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + new RedisProviderSettings.SentinelProperties( + "ca-coordination", + sentinelEndpoints(), + null, + authentication( + "sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca")), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("data-node") + .hasMessageContaining("at least 3") + .hasMessageNotContaining("secret://redis/data/password"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + sentinelDeployment( + List.of( + endpoint("redis-primary.internal", 6379), + endpoint("redis-replica-a.internal", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("data-node") + .hasMessageContaining("at least 3") + .hasMessageNotContaining("secret://redis/data/password"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + sentinelDeployment( + List.of( + endpoint("redis-primary.internal", 6379), + endpoint("redis-primary.internal", 6379), + endpoint("redis-replica-b.internal", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("data-node") + .hasMessageContaining("duplicate") + .hasMessageNotContaining("secret://redis/data/password"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + sentinelDeployment( + List.of( + endpoint("redis primary.internal", 6379), + endpoint("redis-replica-a.internal", 6379), + endpoint("redis-replica-b.internal", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("data-node") + .hasMessageContaining("host is invalid") + .hasMessageNotContaining("secret://redis/data/password"); + + assertThatThrownBy( + () -> + factory.compileRegistered( + provider( + Map.of( + "coord-main", + sentinelDeployment( + List.of( + endpoint("redis-primary.internal", 0), + endpoint("redis-replica-a.internal", 6379), + endpoint("redis-replica-b.internal", 6379)))), + Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("data-node") + .hasMessageContaining("port must be in 1..65535") + .hasMessageNotContaining("secret://redis/data/password"); + } + + @Test + void rolesMustReferenceExistingDeploymentsAndCannotShareOnePhysicalDeployment() { + RedisProviderSettings.DeploymentProperties standalone = + standaloneDeployment(List.of(endpoint("redis.internal", 6379))); + + assertThatThrownBy( + () -> + factory.compileActive( + provider( + Map.of("cache-main", standalone), + Map.of( + RedisRole.CACHE, + new RedisRoleBinding("missing-deployment", false, "allkeys-lfu"))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing-deployment"); + + for (Map incompatible : + List.of( + bindings(RedisRole.CACHE, RedisRole.COORDINATION), + bindings(RedisRole.CACHE, RedisRole.SESSION), + bindings(RedisRole.COORDINATION, RedisRole.SESSION))) { + assertThatThrownBy( + () -> + factory.compileActive(provider(Map.of("shared-main", standalone), incompatible))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("shared-main") + .hasMessageContaining("co-locate"); + } + } + + @Test + void rolesRequireTheirCanonicalRequiredAndEvictionPolicies() { + RedisProviderSettings.DeploymentProperties standalone = + standaloneDeployment(List.of(endpoint("redis.internal", 6379))); + + assertThatThrownBy( + () -> + factory.compileActive( + provider( + Map.of("cache-main", standalone), + Map.of( + RedisRole.CACHE, + new RedisRoleBinding("cache-main", true, "allkeys-lfu"))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CACHE") + .hasMessageContaining("optional"); + + assertThatThrownBy( + () -> + factory.compileActive( + provider( + Map.of("coord-main", standalone), + Map.of( + RedisRole.COORDINATION, + new RedisRoleBinding("coord-main", true, "allkeys-lru"))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("COORDINATION") + .hasMessageContaining("noeviction"); + + assertThatThrownBy( + () -> + factory.compileActive( + provider( + Map.of("session-main", standalone), + Map.of( + RedisRole.SESSION, + new RedisRoleBinding("session-main", false, "noeviction"))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SESSION") + .hasMessageContaining("required"); + + assertThatThrownBy( + () -> + factory.compileActive( + provider( + Map.of("cache-main", standalone), + Map.of(RedisRole.CACHE, new RedisRoleBinding("cache-main", false, null))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("eviction"); + } + + @Test + void providerDefinitionsAloneAreRegisteredButInert() { + RedisProviderSettings properties = + provider( + Map.of( + "cache-main", + standaloneDeployment(List.of(endpoint("cache.internal", 6379))), + "coord-main", + deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + sentinelProperties(), + null, + 0, + dataAuthentication(), + dataTls())), + Map.of()); + + assertThat(factory.compileRegistered(properties)).containsOnlyKeys("cache-main", "coord-main"); + assertThat(factory.compileActive(properties)).isEmpty(); + assertThat(properties.hasActiveRoleBindings()).isFalse(); + } + + private static RedisProviderSettings provider( + Map deployments, + Map roles) { + return new RedisProviderSettings(deployments, roles); + } + + private static RedisProviderSettings.DeploymentProperties standaloneDeployment( + List endpoints) { + return deployment( + RedisProviderSettings.Topology.STANDALONE, + new RedisProviderSettings.StandaloneProperties(endpoints), + null, + null, + 0, + dataAuthentication(), + dataTls()); + } + + private static RedisProviderSettings.DeploymentProperties clusterDeployment( + int database, List endpoints) { + return deployment( + RedisProviderSettings.Topology.CLUSTER, + null, + null, + new RedisProviderSettings.ClusterProperties(endpoints), + database, + dataAuthentication(), + dataTls()); + } + + private static RedisProviderSettings.DeploymentProperties deployment( + RedisProviderSettings.Topology topology, + RedisProviderSettings.StandaloneProperties standalone, + RedisProviderSettings.SentinelProperties sentinel, + RedisProviderSettings.ClusterProperties cluster, + int database, + RedisProviderSettings.AuthenticationProperties authentication, + RedisProviderSettings.TlsProperties tls) { + return new RedisProviderSettings.DeploymentProperties( + topology, standalone, sentinel, cluster, database, authentication, tls); + } + + private static RedisProviderSettings.SentinelProperties sentinelProperties() { + return new RedisProviderSettings.SentinelProperties( + "ca-coordination", + sentinelEndpoints(), + dataEndpoints(), + authentication("sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca")); + } + + private static RedisProviderSettings.DeploymentProperties sentinelDeployment( + List dataEndpoints) { + return deployment( + RedisProviderSettings.Topology.SENTINEL, + null, + new RedisProviderSettings.SentinelProperties( + "ca-coordination", + sentinelEndpoints(), + dataEndpoints, + authentication("sentinel-runtime", "secret://redis/sentinel/password"), + tls("secret://redis/sentinel/ca")), + null, + 0, + dataAuthentication(), + dataTls()); + } + + private static List sentinelEndpoints() { + return List.of( + endpoint("sentinel-a.internal", 26379), + endpoint("sentinel-b.internal", 26379), + endpoint("sentinel-c.internal", 26379)); + } + + private static List dataEndpoints() { + return List.of( + endpoint("redis-primary.internal", 6379), + endpoint("redis-replica-a.internal", 6379), + endpoint("redis-replica-b.internal", 6379)); + } + + private static RedisProviderSettings.EndpointProperties endpoint(String host, int port) { + return new RedisProviderSettings.EndpointProperties(host, port); + } + + private static RedisProviderSettings.AuthenticationProperties dataAuthentication() { + return authentication("coordination-runtime", "secret://redis/data/password"); + } + + private static RedisProviderSettings.AuthenticationProperties authentication( + String username, String passwordReference) { + return new RedisProviderSettings.AuthenticationProperties(username, passwordReference); + } + + private static RedisProviderSettings.TlsProperties dataTls() { + return tls("secret://redis/data/ca"); + } + + private static RedisProviderSettings.TlsProperties tls(String trustBundleReference) { + return new RedisProviderSettings.TlsProperties(true, true, trustBundleReference); + } + + private static Map bindings(RedisRole first, RedisRole second) { + return Map.of(first, binding(first, "shared-main"), second, binding(second, "shared-main")); + } + + private static RedisRoleBinding binding(RedisRole role, String deploymentId) { + return switch (role) { + case CACHE -> new RedisRoleBinding(deploymentId, false, "allkeys-lfu"); + case COORDINATION, SESSION -> new RedisRoleBinding(deploymentId, true, "noeviction"); + }; + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettingsBindingTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettingsBindingTest.java new file mode 100644 index 0000000..1e6bd03 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/config/RedisProviderSettingsBindingTest.java @@ -0,0 +1,222 @@ +package dev.caskeleton.adapter.outbound.cache.redis.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class RedisProviderSettingsBindingTest { + + @Test + void bindsCanonicalStandaloneSentinelClusterDeploymentsAndRoleMap() { + Map values = new LinkedHashMap<>(); + values.put("ca-skeleton.providers.redis.deployments.cache-main.topology", "standalone"); + values.put( + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].host", + "cache.internal"); + values.put( + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].port", 6380); + dataChannel(values, "cache-main", 2, "cache"); + + values.put("ca-skeleton.providers.redis.deployments.coord-main.topology", "sentinel"); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.master-name", + "coordination-master"); + for (int index = 0; index < 3; index++) { + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.endpoints[" + + index + + "].host", + "sentinel-" + index + ".internal"); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.endpoints[" + + index + + "].port", + 26379); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.data-endpoints[" + + index + + "].host", + "redis-data-" + index + ".internal"); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.data-endpoints[" + + index + + "].port", + 6379); + } + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.authentication.username", + "sentinel-runtime"); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.authentication.password-reference", + "secret://redis/sentinel/password"); + values.put("ca-skeleton.providers.redis.deployments.coord-main.sentinel.tls.enabled", true); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.tls.verify-hostname", true); + values.put( + "ca-skeleton.providers.redis.deployments.coord-main.sentinel.tls.trust-bundle-reference", + "secret://redis/sentinel/ca"); + dataChannel(values, "coord-main", 4, "coord"); + + values.put("ca-skeleton.providers.redis.deployments.session-main.topology", "cluster"); + for (int index = 0; index < 3; index++) { + values.put( + "ca-skeleton.providers.redis.deployments.session-main.cluster.endpoints[" + + index + + "].host", + "cluster-" + index + ".internal"); + values.put( + "ca-skeleton.providers.redis.deployments.session-main.cluster.endpoints[" + + index + + "].port", + 6379); + } + dataChannel(values, "session-main", 0, "session"); + + values.put("ca-skeleton.providers.redis.roles.cache.deployment-id", "cache-main"); + values.put("ca-skeleton.providers.redis.roles.cache.required", false); + values.put("ca-skeleton.providers.redis.roles.cache.expected-eviction", "allkeys-lfu"); + values.put("ca-skeleton.providers.redis.roles.coordination.deployment-id", "coord-main"); + values.put("ca-skeleton.providers.redis.roles.coordination.required", true); + values.put("ca-skeleton.providers.redis.roles.coordination.expected-eviction", "noeviction"); + values.put("ca-skeleton.providers.redis.roles.session.deployment-id", "session-main"); + values.put("ca-skeleton.providers.redis.roles.session.required", true); + values.put("ca-skeleton.providers.redis.roles.session.expected-eviction", "noeviction"); + values.put("ca-skeleton.providers.redis.runtime.connect-timeout", "125ms"); + values.put("ca-skeleton.providers.redis.runtime.tls-handshake-timeout", "450ms"); + values.put("ca-skeleton.providers.redis.runtime.sentinel-discovery-refresh-period", "45s"); + values.put("ca-skeleton.providers.redis.runtime.semantic-probe-minimum-interval", "7s"); + values.put("ca-skeleton.providers.redis.runtime.semantic-probe-maximum-staleness", "20s"); + + RedisProviderSettings properties = + new Binder(new MapConfigurationPropertySource(values)) + .bind("ca-skeleton.providers.redis", Bindable.of(RedisProviderSettings.class)) + .orElseThrow(() -> new AssertionError("Redis provider properties did not bind")); + + assertThat(properties.deployments()) + .containsOnlyKeys("cache-main", "coord-main", "session-main"); + assertThat(properties.deployments().get("cache-main").standalone().endpoints()) + .containsExactly(new RedisProviderSettings.EndpointProperties("cache.internal", 6380)); + assertThat(properties.deployments().get("coord-main").sentinel().masterName()) + .isEqualTo("coordination-master"); + assertThat(properties.deployments().get("coord-main").sentinel().endpoints()).hasSize(3); + assertThat(properties.deployments().get("coord-main").sentinel().dataEndpoints()) + .containsExactly( + new RedisProviderSettings.EndpointProperties("redis-data-0.internal", 6379), + new RedisProviderSettings.EndpointProperties("redis-data-1.internal", 6379), + new RedisProviderSettings.EndpointProperties("redis-data-2.internal", 6379)); + assertThat(properties.deployments().get("session-main").cluster().endpoints()).hasSize(3); + assertThat(properties.roles()) + .containsEntry(RedisRole.CACHE, new RedisRoleBinding("cache-main", false, "allkeys-lfu")) + .containsEntry( + RedisRole.COORDINATION, new RedisRoleBinding("coord-main", true, "noeviction")) + .containsEntry(RedisRole.SESSION, new RedisRoleBinding("session-main", true, "noeviction")); + assertThat(properties.runtime().clientSettings().connectTimeout()) + .isEqualTo(java.time.Duration.ofMillis(125)); + assertThat(properties.runtime().clientSettings().tlsHandshakeTimeout()) + .isEqualTo(java.time.Duration.ofMillis(450)); + assertThat(properties.runtime().sentinelDiscoveryRefreshPeriod()) + .isEqualTo(Duration.ofSeconds(45)); + assertThat(properties.runtime().semanticProbeMinimumInterval()) + .isEqualTo(Duration.ofSeconds(7)); + assertThat(properties.runtime().semanticProbeMaximumStaleness()) + .isEqualTo(Duration.ofSeconds(20)); + } + + @Test + void defaultsSentinelDiscoveryRefreshPeriodToThirtySeconds() { + RedisProviderSettings properties = new RedisProviderSettings(Map.of(), Map.of()); + + assertThat(properties.runtime().sentinelDiscoveryRefreshPeriod()) + .isEqualTo(Duration.ofSeconds(30)); + } + + @Test + void acceptsSentinelDiscoveryRefreshPeriodInclusiveBoundaries() { + assertThat(runtime(Duration.ofSeconds(5)).sentinelDiscoveryRefreshPeriod()) + .isEqualTo(Duration.ofSeconds(5)); + assertThat(runtime(Duration.ofMinutes(5)).sentinelDiscoveryRefreshPeriod()) + .isEqualTo(Duration.ofMinutes(5)); + } + + @Test + void rejectsSentinelDiscoveryRefreshPeriodOutsideInclusiveBoundaries() { + assertThatThrownBy(() -> runtime(Duration.ofSeconds(4))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sentinel discovery refresh period") + .hasMessageContaining("5s..5m"); + assertThatThrownBy(() -> runtime(Duration.ofMinutes(5).plusMillis(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sentinel discovery refresh period") + .hasMessageContaining("5s..5m"); + } + + @Test + void rejectsSemanticProbeMaximumStalenessBelowMinimumInterval() { + assertThatThrownBy( + () -> + new RedisProviderSettings.RuntimeProperties( + null, + null, + null, + null, + null, + null, + null, + 0, + 0, + null, + 0, + 0, + 0, + null, + null, + null, + Duration.ofSeconds(15), + Duration.ofSeconds(5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximum staleness") + .hasMessageContaining("minimum interval"); + } + + private static RedisProviderSettings.RuntimeProperties runtime(Duration refreshPeriod) { + return new RedisProviderSettings.RuntimeProperties( + null, + null, + null, + null, + null, + null, + null, + 0, + 0, + null, + 0, + 0, + 0, + null, + null, + refreshPeriod, + null, + null); + } + + private static void dataChannel( + Map values, String deploymentId, int database, String secretNamespace) { + String prefix = "ca-skeleton.providers.redis.deployments." + deploymentId; + values.put(prefix + ".database", database); + values.put(prefix + ".authentication.username", deploymentId + "-runtime"); + values.put( + prefix + ".authentication.password-reference", + "secret://redis/" + secretNamespace + "/password"); + values.put(prefix + ".tls.enabled", true); + values.put(prefix + ".tls.verify-hostname", true); + values.put(prefix + ".tls.trust-bundle-reference", "secret://redis/" + secretNamespace + "/ca"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoaderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoaderTest.java new file mode 100644 index 0000000..8b501be --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/readiness/RedisTestImageRegistryLoaderTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.outbound.cache.redis.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RedisTestImageRegistryLoaderTest { + + private static final String DIGEST = + "dfa18828cbc07b3ae6a95ec7343f6c214fdee2d836197b4be8e9904420762cd8"; + private static final String VALID_IMAGES = + """ + redis.below-minimum.image=redis:7.0.15-alpine@sha256:%s + redis.minimum.image=redis:7.2.14-alpine@sha256:%s + redis.next-minor.image=redis:7.4.9-alpine@sha256:%s + redis.approved.image=redis:7.4.9-alpine@sha256:%s + toxiproxy.image=ghcr.io/shopify/toxiproxy:2.12.0@sha256:%s + """ + .formatted(DIGEST, DIGEST, DIGEST, DIGEST, DIGEST); + + @TempDir Path temporaryDirectory; + + @Test + void loadsTheCanonicalRepositoryImageRegistry() throws IOException { + RedisTestImageRegistry registry = + RedisTestImageRegistryLoader.load(repositoryFile("gradle/redis-test-images.properties")); + + assertThat(registry.images()).containsOnlyKeys(RedisTestImageRegistry.REQUIRED_IMAGE_KEYS); + assertThat(registry.image("redis.minimum.image")) + .isEqualTo("redis:7.2.14-alpine@sha256:" + DIGEST); + } + + @Test + void rejectsTagOnlyLatestPlaceholderAndNonExactVersionReferences() throws IOException { + assertInvalidImage("redis:7.2.14-alpine", "sha256"); + assertInvalidImage("redis:latest@sha256:" + DIGEST, "latest"); + assertInvalidImage("redis:@sha256:" + DIGEST, "placeholder"); + assertInvalidImage("redis:${REDIS_VERSION}@sha256:" + DIGEST, "placeholder"); + } + + @Test + void rejectsInvalidOrPlaceholderDigests() throws IOException { + assertInvalidImage("redis:7.2.14-alpine@sha256:abcdef", "digest"); + assertInvalidImage( + "redis:7.2.14-alpine@sha256:" + + "0000000000000000000000000000000000000000000000000000000000000000", + "placeholder"); + } + + @Test + void rejectsMissingUnknownOrDuplicateImageKeys() throws IOException { + assertThatThrownBy( + () -> + RedisTestImageRegistryLoader.load( + writeImages(VALID_IMAGES.replaceFirst("toxiproxy\\.image=.*\\n", "")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toxiproxy.image"); + + assertThatThrownBy( + () -> + RedisTestImageRegistryLoader.load( + writeImages(VALID_IMAGES + "redis.unregistered.image=redis:7.4.9@" + DIGEST))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("redis.unregistered.image"); + + assertThatThrownBy( + () -> + RedisTestImageRegistryLoader.load( + writeImages( + VALID_IMAGES + "redis.minimum.image=redis:7.2.14-alpine@sha256:" + DIGEST))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + } + + private void assertInvalidImage(String image, String expectedMessage) throws IOException { + assertThatThrownBy( + () -> + RedisTestImageRegistryLoader.load( + writeImages( + VALID_IMAGES.replaceFirst( + "redis\\.minimum\\.image=.*", + Matcher.quoteReplacement("redis.minimum.image=" + image))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(expectedMessage); + } + + private Path writeImages(String content) throws IOException { + Path file = temporaryDirectory.resolve("redis-test-images.properties"); + return Files.writeString(file, content); + } + + private static Path repositoryFile(String sourceRelativePath) { + Path cursor = Path.of("").toAbsolutePath().normalize(); + while (cursor != null) { + Path candidate = cursor.resolve(sourceRelativePath); + if (Files.isRegularFile(candidate)) { + return candidate; + } + Path nestedSourceCandidate = cursor.resolve("src").resolve(sourceRelativePath); + if (Files.isRegularFile(nestedSourceCandidate)) { + return nestedSourceCandidate; + } + cursor = cursor.getParent(); + } + throw new IllegalStateException("Repository file not found: " + sourceRelativePath); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProviderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProviderTest.java new file mode 100644 index 0000000..27ac5a2 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialMaterialProviderTest.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RedisCredentialMaterialProviderTest { + + @Test + void resolvesOnlyAnExplicitReferenceWithoutRenderingTheReferenceOrSecret() { + RedisSecretReference reference = RedisSecretReference.parse("secret://redis/cache/password"); + RedisCredentialMaterialProvider provider = + requested -> { + assertThat(requested).isEqualTo(reference); + return new VersionedRedisCredentialMaterial( + "credential-v7", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from("plain-password".toCharArray())); + }; + + try (VersionedRedisCredentialMaterial material = provider.resolve(reference)) { + assertThat(reference.toString()).doesNotContain("redis/cache/password").contains("REDACTED"); + assertThat(material.toString()) + .contains("credential-v7") + .doesNotContain("plain-password") + .doesNotContain("redis/cache/password"); + } + } + + @Test + void wipesEveryTemporaryViewAndRejectsUseAfterDestroy() { + DestroyableRedisSecret secret = DestroyableRedisSecret.from("temporary-password".toCharArray()); + AtomicReference temporary = new AtomicReference<>(); + + String observed = + secret.use( + characters -> { + temporary.set(characters); + return new String(characters); + }); + + assertThat(observed).isEqualTo("temporary-password"); + assertThat(temporary.get()).containsOnly('\0'); + + secret.destroy(); + assertThat(secret.isDestroyed()).isTrue(); + assertThat(secret.toString()).doesNotContain("temporary-password"); + assertThatThrownBy(() -> secret.use(String::new)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("destroyed"); + } + + @Test + void rejectsBlankOrPlaintextReferencesAndInvalidMaterialMetadata() { + assertThatThrownBy(() -> RedisSecretReference.parse("plain-password")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("secret://"); + assertThatThrownBy(() -> RedisSecretReference.parse(" ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new VersionedRedisCredentialMaterial( + " ", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from("secret".toCharArray()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("version"); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinatorTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinatorTest.java new file mode 100644 index 0000000..ac5bc9c --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisCredentialRotationCoordinatorTest.java @@ -0,0 +1,248 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RedisCredentialRotationCoordinatorTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + + @Test + void ignoresDuplicateAndOutOfOrderVersionsWithoutBuildingAClient() { + AtomicInteger builds = new AtomicInteger(); + FakeRuntime initial = new FakeRuntime("cache-v4"); + try (RedisCredentialRotationCoordinator coordinator = + coordinator( + candidate(4, NOW.plusSeconds(60), initial), + version -> { + builds.incrementAndGet(); + return candidate(version, NOW.plusSeconds(60), new FakeRuntime("cache-v" + version)); + }, + ignored -> {}, + () -> 5)) { + + assertThat(coordinator.rotate(4).join().status()) + .isEqualTo(RedisCredentialRotationCoordinator.Status.IGNORED_STALE); + assertThat(coordinator.rotate(3).join().status()) + .isEqualTo(RedisCredentialRotationCoordinator.Status.IGNORED_STALE); + assertThat(coordinator.snapshot().version()).isEqualTo(4); + assertThat(builds).hasValue(0); + } + } + + @Test + void probesThenAtomicallySwapsAndClosesThePreviousRuntime() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime replacement = new FakeRuntime("cache-v2"); + List events = new ArrayList<>(); + try (RedisCredentialRotationCoordinator coordinator = + coordinator( + candidate(1, NOW.plusSeconds(30), initial), + version -> { + events.add("build-" + version); + return candidate(version, NOW.plusSeconds(90), replacement); + }, + runtime -> events.add("probe-" + runtime.deploymentId()), + () -> 2)) { + + RedisCredentialRotationCoordinator.RotationResult result = coordinator.rotate(2).join(); + + assertThat(result.status()).isEqualTo(RedisCredentialRotationCoordinator.Status.APPLIED); + assertThat(coordinator.snapshot().version()).isEqualTo(2); + assertThat(coordinator.snapshot().deploymentId()).isEqualTo("cache-v2"); + assertThat(events).containsExactly("build-2", "probe-cache-v2"); + assertThat(initial.closed).isTrue(); + assertThat(replacement.closed).isFalse(); + } + } + + @Test + void failedProbeClosesCandidateAndPreservesPreviousRuntimeWithoutLeakingFailureText() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime rejected = new FakeRuntime("cache-v2"); + try (RedisCredentialRotationCoordinator coordinator = + coordinator( + candidate(1, NOW.plusSeconds(30), initial), + version -> candidate(version, NOW.plusSeconds(90), rejected), + ignored -> { + throw new IllegalStateException( + "plain-secret-value at secret://redis/cache/password"); + }, + () -> 2)) { + + RedisCredentialRotationCoordinator.RotationResult result = coordinator.rotate(2).join(); + + assertThat(result.status()).isEqualTo(RedisCredentialRotationCoordinator.Status.FAILED); + assertThat(result.toString()) + .doesNotContain("plain-secret-value") + .doesNotContain("secret://redis/cache/password"); + assertThat(coordinator.snapshot().version()).isEqualTo(1); + assertThat(initial.closed).isFalse(); + assertThat(rejected.closed).isTrue(); + } + } + + @Test + void rejectsAnAlreadyExpiredCandidateBeforeProbeAndPreservesPreviousRuntime() { + FakeRuntime initial = new FakeRuntime("cache-v1"); + FakeRuntime expired = new FakeRuntime("cache-v2"); + AtomicInteger probes = new AtomicInteger(); + try (RedisCredentialRotationCoordinator coordinator = + coordinator( + candidate(1, NOW.plusSeconds(30), initial), + version -> candidate(version, NOW.minusSeconds(1), expired), + ignored -> probes.incrementAndGet(), + () -> 2)) { + + RedisCredentialRotationCoordinator.RotationResult result = coordinator.rotate(2).join(); + + assertThat(result.status()).isEqualTo(RedisCredentialRotationCoordinator.Status.FAILED); + assertThat(coordinator.snapshot().version()).isEqualTo(1); + assertThat(initial.closed).isFalse(); + assertThat(expired.closed).isTrue(); + assertThat(probes).hasValue(0); + } + } + + @Test + void expiryTriggersFreshVersionResolutionAndRotation() { + FakeRuntime initial = new FakeRuntime("cache-v7"); + FakeRuntime replacement = new FakeRuntime("cache-v8"); + AtomicInteger versionResolutions = new AtomicInteger(); + try (RedisCredentialRotationCoordinator coordinator = + coordinator( + candidate(7, NOW.minusSeconds(1), initial), + version -> candidate(version, NOW.plusSeconds(120), replacement), + ignored -> {}, + () -> { + versionResolutions.incrementAndGet(); + return 8; + })) { + + RedisCredentialRotationCoordinator.RotationResult result = + coordinator.refreshIfExpired(NOW).join(); + + assertThat(result.status()).isEqualTo(RedisCredentialRotationCoordinator.Status.APPLIED); + assertThat(coordinator.snapshot().version()).isEqualTo(8); + assertThat(versionResolutions).hasValue(1); + assertThat(initial.closed).isTrue(); + } + } + + @Test + void boundedSerializedExecutorRejectsOverflowAndCloseRejectsNewRotations() throws Exception { + FakeRuntime initial = new FakeRuntime("cache-v1"); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger concurrent = new AtomicInteger(); + AtomicInteger maximumConcurrent = new AtomicInteger(); + RedisCredentialRotationCoordinator coordinator = + coordinator( + candidate(1, NOW.plusSeconds(30), initial), + version -> { + int active = concurrent.incrementAndGet(); + maximumConcurrent.accumulateAndGet(active, Math::max); + try { + if (version == 2) { + entered.countDown(); + release.await(5, TimeUnit.SECONDS); + } + return candidate( + version, NOW.plusSeconds(90), new FakeRuntime("cache-v" + version)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted"); + } finally { + concurrent.decrementAndGet(); + } + }, + ignored -> {}, + () -> 4, + 1); + try { + CompletableFuture first = + coordinator.rotate(2); + assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue(); + CompletableFuture queued = + coordinator.rotate(3); + + RedisCredentialRotationCoordinator.RotationResult overflow = coordinator.rotate(4).join(); + + assertThat(overflow.status()) + .isEqualTo(RedisCredentialRotationCoordinator.Status.REJECTED_OVERLOADED); + release.countDown(); + assertThat(first.join().status()) + .isEqualTo(RedisCredentialRotationCoordinator.Status.APPLIED); + assertThat(queued.join().status()) + .isEqualTo(RedisCredentialRotationCoordinator.Status.APPLIED); + assertThat(maximumConcurrent).hasValue(1); + } finally { + release.countDown(); + coordinator.close(); + } + + assertThat(coordinator.rotate(5).join().status()) + .isEqualTo(RedisCredentialRotationCoordinator.Status.CLOSED); + assertThat(initial.closed).isTrue(); + } + + private static RedisCredentialRotationCoordinator coordinator( + RedisCredentialRotationCoordinator.Candidate initial, + RedisCredentialRotationCoordinator.CandidateFactory factory, + RedisCredentialRotationCoordinator.Probe probe, + RedisCredentialRotationCoordinator.VersionSource versionSource) { + return coordinator(initial, factory, probe, versionSource, 8); + } + + private static RedisCredentialRotationCoordinator coordinator( + RedisCredentialRotationCoordinator.Candidate initial, + RedisCredentialRotationCoordinator.CandidateFactory factory, + RedisCredentialRotationCoordinator.Probe probe, + RedisCredentialRotationCoordinator.VersionSource versionSource, + int queueCapacity) { + return new RedisCredentialRotationCoordinator( + initial, + factory, + probe, + versionSource, + Clock.fixed(NOW, ZoneOffset.UTC), + queueCapacity, + Duration.ofSeconds(2)); + } + + private static RedisCredentialRotationCoordinator.Candidate candidate( + long version, Instant expiresAt, FakeRuntime runtime) { + return new RedisCredentialRotationCoordinator.Candidate(version, expiresAt, runtime); + } + + private static final class FakeRuntime implements RedisRotatableRuntime { + + private final String deploymentId; + private volatile boolean closed; + + private FakeRuntime(String deploymentId) { + this.deploymentId = deploymentId; + } + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProviderTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProviderTest.java new file mode 100644 index 0000000..3328349 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/security/RedisTrustMaterialProviderTest.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.outbound.cache.redis.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings; +import io.lettuce.core.SslOptions; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RedisTrustMaterialProviderTest { + + private static final Instant NOW = Instant.parse("2028-01-01T00:00:00Z"); + + @Test + void appliesVersionedPemAsExplicitTrustManagerAndHandshakeTimeoutThenDestroysMaterial() + throws Exception { + CapturingTrustProvider provider = new CapturingTrustProvider(validPem()); + RedisSslOptionsFactory factory = + new RedisSslOptionsFactory(provider, Clock.fixed(NOW, ZoneOffset.UTC)); + + SslOptions options = + factory.create( + new RedisDeploymentSettings.Tls(true, true, "secret://redis/data/ca"), + Duration.ofMillis(450)); + + assertThat(options.getHandshakeTimeout()).isEqualTo(Duration.ofMillis(450)); + assertThat(options.createSslContextBuilder()).isNotNull(); + assertThat(provider.references).containsExactly("secret://redis/data/ca"); + assertThat(provider.materials) + .allSatisfy(material -> assertThat(material.isDestroyed()).isTrue()); + } + + @Test + void rejectsEmptyInvalidOrExpiredPemInsteadOfFallingBackToJvmDefaultOrTrustAll() { + for (byte[] pem : List.of("not-a-certificate".getBytes(), new byte[0])) { + CapturingTrustProvider provider = new CapturingTrustProvider(pem); + RedisSslOptionsFactory factory = + new RedisSslOptionsFactory(provider, Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + factory.create( + new RedisDeploymentSettings.Tls(true, true, "secret://redis/data/invalid-ca"), + Duration.ofMillis(450))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("PEM") + .hasMessageNotContaining("not-a-certificate"); + assertThat(provider.materials) + .allSatisfy(material -> assertThat(material.isDestroyed()).isTrue()); + } + + RedisTrustMaterialProvider expired = + ignored -> + new VersionedRedisTrustMaterial( + "trust-v1", NOW.minusSeconds(1), DestroyableRedisPem.from(validPem())); + assertThatThrownBy( + () -> + new RedisSslOptionsFactory(expired, Clock.fixed(NOW, ZoneOffset.UTC)) + .create( + new RedisDeploymentSettings.Tls( + true, true, "secret://redis/data/expired-ca"), + Duration.ofMillis(450))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expired"); + } + + @Test + void sanitizesTrustProviderFailures() { + RedisTrustMaterialProvider leakingProvider = + ignored -> { + throw new IllegalStateException("plain-trust-material at secret://redis/data/private-ca"); + }; + + assertThatThrownBy( + () -> + new RedisSslOptionsFactory(leakingProvider, Clock.fixed(NOW, ZoneOffset.UTC)) + .create( + new RedisDeploymentSettings.Tls( + true, true, "secret://redis/data/private-ca"), + Duration.ofMillis(450))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("resolution failed") + .hasMessageNotContaining("plain-trust-material") + .hasMessageNotContaining("secret://redis/data/private-ca") + .hasNoCause(); + } + + private static byte[] validPem() { + try { + return RedisTrustMaterialProviderTest.class + .getResourceAsStream("/redis-test-ca.pem") + .readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Redis test CA could not be read", exception); + } + } + + private static final class CapturingTrustProvider implements RedisTrustMaterialProvider { + + private final byte[] pem; + private final List references = new ArrayList<>(); + private final List materials = new ArrayList<>(); + + private CapturingTrustProvider(byte[] pem) { + this.pem = pem.clone(); + } + + @Override + public VersionedRedisTrustMaterial resolve(RedisSecretReference reference) { + references.add(reference.valueForResolution()); + VersionedRedisTrustMaterial material = + new VersionedRedisTrustMaterial( + "trust-v1", NOW.plusSeconds(3600), DestroyableRedisPem.from(pem)); + materials.add(material); + return material; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/resources/redis-test-ca.pem b/src/adapter/outbound/cache-redis/src/test/resources/redis-test-ca.pem new file mode 100644 index 0000000..3fa45b4 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/resources/redis-test-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHTCCAgWgAwIBAgIUP6YpE8d2EpWEIGtL2ZnGr9hObDowDQYJKoZIhvcNAQEL +BQAwHjEcMBoGA1UEAwwTcmVkaXMtdGVzdC5pbnRlcm5hbDAeFw0yNjA3MjkwNjU1 +MzJaFw0zNjA3MjYwNjU1MzJaMB4xHDAaBgNVBAMME3JlZGlzLXRlc3QuaW50ZXJu +YWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/HQHkxjZ3A2pwC+Y7 +SMszc2qh17TpU7YrZ8GThok+Ci8uHFWX6b9eCQ8Lw3hnHaGwcUdaeEyUaf2gFeDx +EhDrdS9mvX0O7BPPcvFS3YaKusZsqut7axxU5qthnaqgfesL/bsUe8etD9gbs4FN +dkkJ1IAVpFLyRXitvneUqYe94IBoGEUaaHcG5WpIPbLfaBsShVtcUQy9flVLo00I +phxmD0AjKSBV8otYJHrau7NG8oSHzfoRiBnuKeCsWbi8xPjSFKW+zG2wIp1lbza/ +ZbvZbKo75vUNKDu8XyNhCUNDGAfTUN9aG1pubBbLNQ8eiYZI5WlVjqIgE8CG8Te0 +a9DZAgMBAAGjUzBRMB0GA1UdDgQWBBQPQnbqpUAkcylLG8YAEsLBkVTl6TAfBgNV +HSMEGDAWgBQPQnbqpUAkcylLG8YAEsLBkVTl6TAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4IBAQByyeOk5igzwA3DanWADOekfXMrLDbgjx5HcEkeWwhw +VLvTa9JYoEG9M6CjFnJa/2oboNfQYQKjuy4UBBOwHqbdSfb+SlW/HZwayG1NCKa8 +uMt/rlOO+s6RZtx5ubxsWQc/BiSWE63cSpv3cgq1HsgMJ2aNIWoEX73maZB9ttMf +eOw/6mjkDUkXGbJ3UyjdlPX0xOxd/763pK0n5x9uIgL92/2wZl/Iw9L7Xzgb3+w1 +lMOAe7CO9Nh0U+ip8bsCpWSetvbuBuZ55sQCnqQ9KAUb6VPzvGwS70I+QwNnuv5u +axI1FBTrqh8eiY4U/Pv4nrVXbO9IPK/3G0104zzUS/sy +-----END CERTIFICATE----- diff --git a/src/adapter/outbound/fileserver/CLAUDE.md b/src/adapter/outbound/fileserver/CLAUDE.md index 45da13a..967fdc6 100644 --- a/src/adapter/outbound/fileserver/CLAUDE.md +++ b/src/adapter/outbound/fileserver/CLAUDE.md @@ -16,12 +16,17 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad - Publish typed tabular data through a bounded producer/sink contract behind `FilePublicationPort`. -- Own CSV encoding, schema validation, formula policy, staging, checksum/counts, file force, and - local exclusive-publication semantics. +- Own exact destination routing, provider-neutral operation/manifest/reference records, CSV + encoding, schema validation, formula policy, staging, checksum/counts, recovery, and provider + publication semantics. +- Implement the first qualified R2 provider, `local-persistent`, behind strict pre-provisioned-root + attestation and no-downgrade force/hard-link semantics. - Return opaque references and explicit publication/durability guarantees; do not expose paths. -- Opt-in: `FileExportConfig` gates publication with - `ca-skeleton.fileserver.enabled=true`; the legacy bean additionally requires - `ca-skeleton.fileserver.legacy-enabled=true` and a separate root. Both default off. +- Opt-in: `FileserverR2Config` gates R2 with `app.fileserver.enabled=true`. + `FileExportConfig` separately gates R1 with `ca-skeleton.fileserver.enabled=true`; the legacy + bean additionally requires `ca-skeleton.fileserver.legacy-enabled=true` and a separate root. + All selectors default off, and R1/R2 simultaneous activation fails before filesystem + initialization. ## Allowed @@ -29,25 +34,29 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad `adapter-outbound-fileserver` entry in `src/config/architecture/modules.json`; `src/build.gradle` enforces it. No `:domain-core`, no sibling adapters. -- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor` - (annotation processor) only. +- External runtime: JDK filesystem, `spring-boot-autoconfigure`, and `slf4j-api`. + `spring-boot-configuration-processor` is annotation-processor-only; no broad Boot starter or + external file-client SDK is allowed. ## Forbidden - Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio` (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). - Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`. -- Advertising local R1 as crash-recoverable R2. Durable operation journal, reconciliation, SFTP, - and NFS/HA evidence are not fully implemented. The local journal only supports single-node - terminal restoration and sealed-artifact resume; it is not cross-node fencing or R2 evidence. +- Advertising `FILE_AND_DIRECTORY_SYNC` as physical device/controller/replica/site power-loss + protection. It is the attested local file/directory force boundary only. +- Advertising `shared-mounted`/NFS, SFTP, cross-node fencing, reaper, retention, quota, + readiness/health, metrics, tracing, or audit as implemented. +- Auto-promoting canonical R1 artifacts: schema v1 is strict read-only compatibility and retains + `PROCESS_LOCAL_SYNC`; R2 writes schema v2 only. - Adding a second production provider without an explicit selector and startup ambiguity tests. - Fully-qualified inline type references; more than one public top-level type per file. ## Tests -`FilePublicationContractTest`, `LocalFilePublicationAdapterTest`, -`LocalPublicationJournalTest`, `LocalFilePublicationRecoveryTest`, `FilePublicationConfigTest`, and -the legacy `FilesystemCsvExportAdapterTest`. +The focused suite includes contract/R1 compatibility, R2 binding/routing, strict root attestation, +canonical control records, secure control/payload operations, deterministic recovery, +forked-process crash/OS-lock qualification, Spring composition, and the legacy adapter. ```bash cd src diff --git a/src/adapter/outbound/fileserver/README.md b/src/adapter/outbound/fileserver/README.md index c4381c1..8b181b1 100644 --- a/src/adapter/outbound/fileserver/README.md +++ b/src/adapter/outbound/fileserver/README.md @@ -5,38 +5,46 @@ File-server publication outbound (driven) adapter. Package root: `application-core` `FilePublicationPort` and temporarily retains the legacy `FileExportPort`. Publication and the legacy compatibility port have separate opt-in selectors. -The allowed/forbidden dependency policy is owned by `src/build.gradle`'s -`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in -[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code -comments. +The allowed/forbidden production dependency policy is owned by the +`adapter-outbound-fileserver` entry in `src/config/architecture/modules.json`; root Gradle +verification reads that registry. Module rules live in [CLAUDE.md](CLAUDE.md); this document +records the **design rationale** lifted out of the code comments. ## Implemented capability -`LocalFilePublicationAdapter` is a local-filesystem R1 provider. The application supplies a typed -schema and streams rows once through a producer/sink callback. The adapter encodes each row without -materializing the whole export, enforces row/encoded-byte/per-cell limits, applies the configured -spreadsheet-formula policy, computes SHA-256 and counts, forces the staged file, and publishes it -with an exclusive atomic hard-link create. After publication it forces both staging and final -directories before recording the terminal journal. Its receipt contains an opaque reference rather than a -server path. A private, forced operation journal records request fingerprints and `WRITING`, `SEALED`, -and `PUBLISHED` state. On a single local filesystem, a restarted adapter can restore a verified -terminal receipt or finish a verified sealed staging artifact without invoking the producer again. -A sealed journal plus a verified final target reconstructs the only supported hard-link protocol -as `UNIQUE_ATOMIC_CREATE` after re-forcing the final directory. -Corrupt/unreadable operation state is exposed only as provider-neutral -`PUBLISH_INDETERMINATE`, never as an adapter-internal exception. -The private control directory and journal shards reject symbolic links before read/write so a -pre-existing internal link cannot redirect journal bytes outside the configured base. -Once a `SEALED` record exists, publish conflicts and unsupported atomic publication preserve the -verified staging artifact for explicit retry/reconciliation instead of deleting the only recovery -evidence. -Operation-scoped JVM and OS file locks serialize cooperating callers on the same local filesystem. +The application-facing contract remains provider-neutral: callers select a logical +`FileDestinationId` through `FilePublicationPort` and never receive a path, host, mount, Spring, or +NIO type. Inside this leaf, `RoutingFilePublicationAdapter` performs an exact destination lookup +before producer invocation. The canonical operation/manifest/reference records and the provider +boundary are shared control-plane concepts; the only implemented R2 persistence/publication +provider is currently `local-persistent`. -Selector: `ca-skeleton.fileserver.enabled=true` (default `false`) enables only the new -`FilePublicationPort`. The overwrite-capable compatibility port additionally requires -`ca-skeleton.fileserver.legacy-enabled=true` and writes under its own legacy root. This module is not -currently a default `app-bootstrap` dependency, so a consuming application must intentionally add -the leaf as well as enable it. +`local-persistent` is an explicit, fail-closed provider for a pre-provisioned absolute filesystem +root. Startup compiles destination policy, rejects implicit/default providers and shared-root +double ownership, attests owner/mode/FileStore/mount-sentinel/path identity, requires +`SecureDirectoryStream`, and probes exclusive create, hard-link publication, and file/directory +force. One provider/control/payload runtime is shared by every destination that names the same +provider ID. + +An accepted publication streams typed rows once, applies CSV/schema/formula/size policy, writes and +forces a private stage, and advances the forced control plane through: + +```text +WRITING -> SEALED -> DATA_PUBLISHED -> MANIFEST_PUBLISHED + -> REFERENCE_PUBLISHED -> PUBLISHED +``` + +Data is published with an exclusive no-overwrite hard link. A private manifest and direct opaque +reference index are forced before the terminal operation record. Recovery uses the operation ID, +validates data/manifest/reference/receipt equality, and either restores the exact receipt, resumes +from verified sealed bytes without replaying the producer, or fails closed as +indeterminate/quarantined. Terminal mismatches preserve all evidence. Operation-scoped JVM and OS +file locks serialize cooperating processes that use the same attested root. + +The R2 selector is `app.fileserver.enabled=true` and defaults to `false` in +`app-bootstrap/application.yml`. The leaf is included by `app-bootstrap`, but disabled composition +performs no root attestation or filesystem initialization. Enabling R2 requires an exact +destination/provider graph and all local attestation inputs. ## Publication contract @@ -56,26 +64,53 @@ R2 durability or cluster-safety evidence. ## Settings -- `ca-skeleton.fileserver.enabled=false` -- `ca-skeleton.fileserver.legacy-enabled=false` -- `ca-skeleton.fileserver.base-directory=./.data/fileserver` -- `ca-skeleton.fileserver.legacy-base-directory=./.data/fileserver-legacy` -- `ca-skeleton.fileserver.destination-id=local-export` -- `ca-skeleton.fileserver.maximum-rows=1000000` -- `ca-skeleton.fileserver.maximum-encoded-bytes=1073741824` +- `app.fileserver.enabled=false` +- `app.fileserver.destinations..provider-ref=` +- `app.fileserver.destinations..required-publication=unique-atomic-create` +- `app.fileserver.destinations..required-durability=file-and-directory-sync` +- `app.fileserver.destinations..maximum-rows` +- `app.fileserver.destinations..maximum-encoded-bytes` +- `app.fileserver.providers..type=local-persistent` +- `app.fileserver.providers..root-directory` +- `app.fileserver.providers..auto-create=false` +- `app.fileserver.providers..strict-path-security=true` +- `app.fileserver.providers..expected-file-store-name` +- `app.fileserver.providers..expected-file-store-type` +- `app.fileserver.providers..mount-sentinel-name` +- `app.fileserver.providers..mount-sentinel-sha256` +- `app.fileserver.providers..expected-owner` +- `app.fileserver.providers..maximum-root-mode` -The provider always fails closed if the filesystem cannot supply exclusive hard-link creation. -There is no copy-to-final or overwrite-capable rename fallback. +The shipped composition maps the five topology-specific values to +`APP_FILESERVER_LOCAL_ROOT`, `APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME`, +`APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE`, +`APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256`, and `APP_FILESERVER_LOCAL_EXPECTED_OWNER`. They are +restart-only and required when `app.fileserver.enabled=true`; blank disabled defaults are not a +local-filesystem fallback. + +Unknown R2 properties fail binding. The provider always fails closed if the filesystem cannot +supply the required secure relative operations, exclusive hard-link creation, and force +boundaries. There is no copy-to-final, overwrite-capable rename, or guarantee downgrade fallback. ## Guarantee boundary -This remains local R1, not Fileserver R2. It now provides single-node request fingerprinting, -forced operation-journal replacement, terminal receipt restoration, and bounded sealed-artifact -reconciliation. It does not yet provide cross-node fencing, exhaustive crash-point qualification, -reference/manifest indexes, background reconciliation/reaping, SFTP, NFS mount identity, -multi-node cleanup, or quota reservation. See -`docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` for the remaining -phases. +`local-persistent` is the only R2 provider implemented and qualified by this module. Its +`FILE_AND_DIRECTORY_SYNC` receipt means the implementation successfully forced the file and +relevant directories inside the attested local filesystem protocol. It does **not** claim physical +device, storage-controller cache, volume-replica, backup, or site-level power-loss protection; +those require deployment/storage evidence. + +`shared-mounted`/NFS and SFTP providers are not implemented. Cross-node producer fencing, +background reconciliation/reaping, retention, quota/backpressure, readiness/health, metrics, +tracing, and audit are also not implemented. No setting or bean for those capabilities is exposed. + +The prior `LocalFilePublicationAdapter` remains a separately selected R1 compatibility runtime +under `ca-skeleton.fileserver.enabled=true`; the overwrite-capable legacy port additionally +requires `ca-skeleton.fileserver.legacy-enabled=true` and a separate root. R1 and R2 selectors +cannot be enabled together. When an attested R2 root contains a canonical terminal R1 journal and +matching root-level artifact, R2 may restore its original `PROCESS_LOCAL_SYNC` receipt read-only. +It never writes schema v1, creates an R2 manifest/reference for that artifact, or promotes its +durability guarantee. ## Tests @@ -85,7 +120,18 @@ phases. - `LocalPublicationJournalTest`: canonical request fingerprint and strict journal integrity. - `LocalFilePublicationRecoveryTest`: restart receipt restoration, sealed resume, conflict, and artifact-integrity handling. -- `FilePublicationConfigTest`: opt-in binding and both port beans. +- `FileserverBindingCompilerTest`: exact destination/provider compilation and guarantee policy. +- `LocalPersistentRootAttestorTest`: root, owner, mode, FileStore, sentinel, path, and capability + attestation. +- `FileserverControlRecordCodecTest`: canonical v2 operation/manifest/reference records and strict + R1 read-only dispatch. +- `LocalPersistentControlPlaneTest`: secure record replacement, locking, and failure boundaries. +- `LocalPersistentPayloadOperationsTest`: secure bounded stage/data operations and force behavior. +- `LocalPersistentPublicationProviderTest` and `LocalPersistentPublicationRecoveryTest`: ordered + publication and deterministic state recovery. +- `FileserverR2ConfigTest` and `FilePublicationConfigTest`: disabled side-effect freedom, exact + routing, selector ambiguity, and R1/R2 bean composition. +- `LocalPersistentCrashRecoveryTest`: forked-process force-boundary and OS-lock qualification. - `FilesystemCsvExportAdapterTest`: legacy compatibility path. ```bash diff --git a/src/adapter/outbound/fileserver/build.gradle b/src/adapter/outbound/fileserver/build.gradle index 888200c..debef5d 100644 --- a/src/adapter/outbound/fileserver/build.gradle +++ b/src/adapter/outbound/fileserver/build.gradle @@ -1,10 +1,8 @@ -// Driven adapter: file server / filesystem exports behind application-core's FileExportPort. Writes -// delimited (CSV) files to a configured base directory — a stand-in for an NFS mount, shared file -// server, or SFTP drop. Its IO path uses only the JDK; Spring Boot autoconfigure and the SLF4J API -// provide conditional composition and diagnostics without an external file-client SDK. Opt-in via -// @ConditionalOnProperty (ca-skeleton.fileserver.enabled), off by default so the module never -// activates unexpectedly. -description = 'Outbound adapter: file server exports (filesystem/CSV)' +// Driven adapter for provider-neutral file publication and legacy CSV export. The only qualified +// R2 provider is local-persistent; shared-mounted/NFS and SFTP are not stand-ins or implemented +// capabilities. Its IO path uses only the JDK. Spring Boot autoconfigure supplies explicit, +// disabled-default R1/R2 composition and SLF4J remains the diagnostics API. +description = 'Outbound adapter: provider-neutral file publication with local-persistent R2' dependencies { implementation project(':application-core') diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java new file mode 100644 index 0000000..b121a70 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java @@ -0,0 +1,198 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Objects; +import java.util.Set; + +/** Framework-free, validated effective descriptor for one adapter-internal destination binding. */ +record CompiledFileDestination( + FileDestinationId destinationId, + String providerId, + Path rootDirectory, + long maximumRows, + long maximumEncodedBytes, + String expectedFileStoreName, + String expectedFileStoreType, + String mountSentinelName, + String mountSentinelSha256, + String expectedOwner, + String maximumRootMode, + Set maximumRootPermissions, + String effectivePolicyRevision, + String effectivePolicyDigest, + String routeToken, + String formatPolicyDigest, + PublicationGuarantee requiredPublicationGuarantee, + DurabilityGuarantee requiredDurabilityGuarantee) { + + CompiledFileDestination { + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + providerId = FileserverR2Validation.requireNormalizedId("providerId", providerId); + rootDirectory = + FileserverR2Validation.requireAbsoluteNormalizedPath("rootDirectory", rootDirectory); + if (maximumRows < 1) { + throw new IllegalArgumentException("maximumRows must be positive"); + } + if (maximumEncodedBytes < 1) { + throw new IllegalArgumentException("maximumEncodedBytes must be positive"); + } + FileserverR2Validation.requireNonBlank("expectedFileStoreName", expectedFileStoreName); + FileserverR2Validation.requireNonBlank("expectedFileStoreType", expectedFileStoreType); + mountSentinelName = FileserverR2Validation.requireSentinelName(mountSentinelName); + mountSentinelSha256 = + FileserverR2Validation.requireSha256("mountSentinelSha256", mountSentinelSha256); + FileserverR2Validation.requireNonBlank("expectedOwner", expectedOwner); + maximumRootPermissions = + FileserverR2Validation.requireMatchingMaximumRootPermissions( + maximumRootMode, maximumRootPermissions); + if (requiredPublicationGuarantee != PublicationGuarantee.UNIQUE_ATOMIC_CREATE) { + throw new IllegalArgumentException( + "requiredPublicationGuarantee must be UNIQUE_ATOMIC_CREATE"); + } + if (requiredDurabilityGuarantee != DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC) { + throw new IllegalArgumentException( + "requiredDurabilityGuarantee must be FILE_AND_DIRECTORY_SYNC"); + } + if (!FilePublicationCanonicalDigests.EFFECTIVE_POLICY_REVISION.equals( + effectivePolicyRevision)) { + throw new IllegalArgumentException( + "effectivePolicyRevision must be exactly " + + FilePublicationCanonicalDigests.EFFECTIVE_POLICY_REVISION); + } + FileserverR2Validation.requireSha256("effectivePolicyDigest", effectivePolicyDigest); + String canonicalEffectivePolicyDigest = + FilePublicationCanonicalDigests.effectivePolicyDigest( + FilePublicationCanonicalDigests.effectivePolicyDescriptor( + destinationId, + providerId, + maximumRows, + maximumEncodedBytes, + requiredPublicationGuarantee, + requiredDurabilityGuarantee)); + if (!canonicalEffectivePolicyDigest.equals(effectivePolicyDigest)) { + throw new IllegalArgumentException( + "effectivePolicyDigest must match the canonical destination policy"); + } + if (!FilePublicationCanonicalDigests.routeToken(effectivePolicyDigest).equals(routeToken)) { + throw new IllegalArgumentException("routeToken must be derived from effectivePolicyDigest"); + } + if (!FilePublicationCanonicalDigests.formatPolicyDigest().equals(formatPolicyDigest)) { + throw new IllegalArgumentException( + "formatPolicyDigest must match the canonical format policy"); + } + } + + CompiledFileDestination( + FileDestinationId destinationId, + String providerId, + Path rootDirectory, + long maximumRows, + long maximumEncodedBytes, + String expectedFileStoreName, + String expectedFileStoreType, + String mountSentinelName, + String mountSentinelSha256, + String expectedOwner, + String maximumRootMode, + Set maximumRootPermissions, + PublicationGuarantee requiredPublicationGuarantee, + DurabilityGuarantee requiredDurabilityGuarantee) { + this( + destinationId, + providerId, + rootDirectory, + maximumRows, + maximumEncodedBytes, + expectedFileStoreName, + expectedFileStoreType, + mountSentinelName, + mountSentinelSha256, + expectedOwner, + maximumRootMode, + maximumRootPermissions, + FilePublicationCanonicalDigests.effectivePolicyDescriptor( + destinationId, + providerId, + maximumRows, + maximumEncodedBytes, + requiredPublicationGuarantee, + requiredDurabilityGuarantee), + requiredPublicationGuarantee, + requiredDurabilityGuarantee); + } + + private CompiledFileDestination( + FileDestinationId destinationId, + String providerId, + Path rootDirectory, + long maximumRows, + long maximumEncodedBytes, + String expectedFileStoreName, + String expectedFileStoreType, + String mountSentinelName, + String mountSentinelSha256, + String expectedOwner, + String maximumRootMode, + Set maximumRootPermissions, + FilePublicationCanonicalDigests.EffectivePolicyDescriptor descriptor, + PublicationGuarantee requiredPublicationGuarantee, + DurabilityGuarantee requiredDurabilityGuarantee) { + this( + destinationId, + providerId, + rootDirectory, + maximumRows, + maximumEncodedBytes, + expectedFileStoreName, + expectedFileStoreType, + mountSentinelName, + mountSentinelSha256, + expectedOwner, + maximumRootMode, + maximumRootPermissions, + FilePublicationCanonicalDigests.compiledIdentity(descriptor), + requiredPublicationGuarantee, + requiredDurabilityGuarantee); + } + + private CompiledFileDestination( + FileDestinationId destinationId, + String providerId, + Path rootDirectory, + long maximumRows, + long maximumEncodedBytes, + String expectedFileStoreName, + String expectedFileStoreType, + String mountSentinelName, + String mountSentinelSha256, + String expectedOwner, + String maximumRootMode, + Set maximumRootPermissions, + FilePublicationCanonicalDigests.CompiledIdentity identity, + PublicationGuarantee requiredPublicationGuarantee, + DurabilityGuarantee requiredDurabilityGuarantee) { + this( + destinationId, + providerId, + rootDirectory, + maximumRows, + maximumEncodedBytes, + expectedFileStoreName, + expectedFileStoreType, + mountSentinelName, + mountSentinelSha256, + expectedOwner, + maximumRootMode, + maximumRootPermissions, + identity.effectivePolicyRevision(), + identity.effectivePolicyDigest(), + identity.routeToken(), + identity.formatPolicyDigest(), + requiredPublicationGuarantee, + requiredDurabilityGuarantee); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java new file mode 100644 index 0000000..4289cba --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java @@ -0,0 +1,255 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.time.Instant; +import java.util.Objects; + +/** Immutable schema-v2 durable operation journal record. */ +record DurablePublicationRecord( + int schemaVersion, + long stateRevision, + State state, + String operationId, + String requestFingerprint, + String effectivePolicyRevision, + String effectivePolicyDigest, + String destinationId, + String providerId, + String fileId, + String routeToken, + String publishedFileName, + String stageFileName, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + String manifestDigest, + String referenceDigest, + Instant createdAt, + Instant sealedAt, + Instant publishedAt, + String lastFailureCode, + String receiptSnapshot) { + + static final int CURRENT_SCHEMA_VERSION = 2; + + DurablePublicationRecord { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("unsupported durable publication record schema"); + } + Objects.requireNonNull(state, "state must be non-null"); + if (stateRevision < state.minimumRevision()) { + throw new IllegalArgumentException("stateRevision is behind the durable publication state"); + } + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + FileserverControlRecordCodec.requireDigest(requestFingerprint, "requestFingerprint"); + FileserverControlRecordCodec.requireText( + effectivePolicyRevision, "effectivePolicyRevision", 128); + FileserverControlRecordCodec.requireDigest(effectivePolicyDigest, "effectivePolicyDigest"); + FileserverControlRecordCodec.requireLogicalId(destinationId, "destinationId"); + FileserverControlRecordCodec.requireLogicalId(providerId, "providerId"); + FileserverControlRecordCodec.requireFileId(fileId); + FileserverControlRecordCodec.requireRouteToken(routeToken); + FileserverControlRecordCodec.requireSegment(publishedFileName, "publishedFileName"); + FileserverControlRecordCodec.requireSegment(stageFileName, "stageFileName"); + FileserverControlRecordCodec.requireInstant(createdAt, "createdAt"); + FileserverControlRecordCodec.requireOptionalFailureCode(lastFailureCode); + FileserverControlRecordCodec.requireOptionalDigest(sha256, "sha256"); + FileserverControlRecordCodec.requireOptionalDigest(manifestDigest, "manifestDigest"); + FileserverControlRecordCodec.requireOptionalDigest(referenceDigest, "referenceDigest"); + FileserverControlRecordCodec.requireNoControl(receiptSnapshot, "receiptSnapshot", 12_288); + + if (byteSize < 0 || rowCount < 0 || columnCount < 0 || formulaMitigatedCount < 0) { + throw new IllegalArgumentException("operation sizes and counts must be non-negative"); + } + FileserverControlRecordCodec.requireFormulaCountWithinCells( + rowCount, columnCount, formulaMitigatedCount, "operation"); + + if (state == State.WRITING) { + requireWritingPresence( + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + manifestDigest, + referenceDigest, + sealedAt, + publishedAt, + lastFailureCode, + receiptSnapshot); + } else if (state == State.QUARANTINED) { + requireQuarantinedPresence( + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + manifestDigest, + referenceDigest, + sealedAt, + publishedAt, + lastFailureCode, + receiptSnapshot, + createdAt); + } else { + requireProgressedPresence( + state, + byteSize, + columnCount, + sha256, + manifestDigest, + referenceDigest, + createdAt, + sealedAt, + publishedAt, + lastFailureCode, + receiptSnapshot); + if (state == State.PUBLISHED) { + FileserverControlRecordCodec.validateReceiptSnapshot( + receiptSnapshot, + operationId, + destinationId, + publishedFileName, + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + publishedAt, + routeToken, + fileId); + } + } + } + + private static void requireWritingPresence( + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + String manifestDigest, + String referenceDigest, + Instant sealedAt, + Instant publishedAt, + String lastFailureCode, + String receiptSnapshot) { + if (byteSize != 0 + || rowCount != 0 + || columnCount != 0 + || formulaMitigatedCount != 0 + || !FileserverControlRecordCodec.allBlank( + sha256, manifestDigest, referenceDigest, lastFailureCode, receiptSnapshot) + || sealedAt != null + || publishedAt != null) { + throw new IllegalArgumentException("WRITING record contains progressed fields"); + } + } + + private static void requireProgressedPresence( + State state, + long byteSize, + int columnCount, + String sha256, + String manifestDigest, + String referenceDigest, + Instant createdAt, + Instant sealedAt, + Instant publishedAt, + String lastFailureCode, + String receiptSnapshot) { + if (byteSize < 0 || columnCount < 1) { + throw new IllegalArgumentException("sealed record sizes are incomplete"); + } + FileserverControlRecordCodec.requireDigest(sha256, "sha256"); + FileserverControlRecordCodec.requireOrderedInstant(createdAt, sealedAt, "sealedAt"); + if (!lastFailureCode.isEmpty()) { + throw new IllegalArgumentException("non-quarantined record contains lastFailureCode"); + } + + requireDigestPresence( + state.minimumRevision() >= State.MANIFEST_PUBLISHED.minimumRevision(), + manifestDigest, + "manifestDigest"); + requireDigestPresence( + state.minimumRevision() >= State.REFERENCE_PUBLISHED.minimumRevision(), + referenceDigest, + "referenceDigest"); + + if (state == State.PUBLISHED) { + FileserverControlRecordCodec.requireOrderedInstant(sealedAt, publishedAt, "publishedAt"); + } else if (publishedAt != null || !receiptSnapshot.isEmpty()) { + throw new IllegalArgumentException("non-terminal record contains terminal receipt fields"); + } + } + + private static void requireQuarantinedPresence( + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + String manifestDigest, + String referenceDigest, + Instant sealedAt, + Instant publishedAt, + String lastFailureCode, + String receiptSnapshot, + Instant createdAt) { + if (lastFailureCode.isEmpty()) { + throw new IllegalArgumentException("QUARANTINED record requires lastFailureCode"); + } + if (publishedAt != null || !receiptSnapshot.isEmpty()) { + throw new IllegalArgumentException("QUARANTINED record cannot contain a receipt"); + } + if (sha256.isEmpty()) { + if (byteSize != 0 + || rowCount != 0 + || columnCount != 0 + || formulaMitigatedCount != 0 + || sealedAt != null + || !manifestDigest.isEmpty() + || !referenceDigest.isEmpty()) { + throw new IllegalArgumentException("unsealed QUARANTINED record is inconsistent"); + } + return; + } + FileserverControlRecordCodec.requireDigest(sha256, "sha256"); + if (columnCount < 1) { + throw new IllegalArgumentException("sealed QUARANTINED record requires columnCount"); + } + FileserverControlRecordCodec.requireOrderedInstant(createdAt, sealedAt, "sealedAt"); + if (!referenceDigest.isEmpty() && manifestDigest.isEmpty()) { + throw new IllegalArgumentException("referenceDigest requires manifestDigest"); + } + } + + private static void requireDigestPresence(boolean required, String digest, String field) { + if (required) { + FileserverControlRecordCodec.requireDigest(digest, field); + } else if (!digest.isEmpty()) { + throw new IllegalArgumentException(field + " appears before its publication state"); + } + } + + enum State { + WRITING(1), + SEALED(2), + DATA_PUBLISHED(3), + MANIFEST_PUBLISHED(4), + REFERENCE_PUBLISHED(5), + PUBLISHED(6), + QUARANTINED(2); + + private final long minimumRevision; + + State(long minimumRevision) { + this.minimumRevision = minimumRevision; + } + + long minimumRevision() { + return minimumRevision; + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java index 8c60280..f37adec 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java @@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; /** * Opt-in wiring for the file-server export adapter. The single {@link FileExportPort} bean is @@ -19,7 +20,7 @@ import org.springframework.context.annotation.Configuration; * mirroring the object-storage module. */ @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(FileExportProperties.class) +@EnableConfigurationProperties(FileExportSettings.class) public class FileExportConfig { @Bean @@ -27,7 +28,9 @@ public class FileExportConfig { prefix = "ca-skeleton.fileserver", name = {"enabled", "legacy-enabled"}, havingValue = "true") - public FileExportPort filesystemCsvExportPort(FileExportProperties properties) { + public FileExportPort filesystemCsvExportPort( + FileExportSettings properties, Environment environment) { + FileserverActivationValidator.rejectAmbiguous(environment); Path publicationRoot = configuredRoot(properties.getBaseDirectory(), "base-directory"); Path legacyRoot = configuredRoot(properties.getLegacyBaseDirectory(), "legacy-base-directory"); Path canonicalPublicationRoot = canonicalDirectory(publicationRoot); @@ -41,7 +44,9 @@ public class FileExportConfig { @Bean @ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true") - public FilePublicationPort localFilePublicationPort(FileExportProperties properties) { + public FilePublicationPort localFilePublicationPort( + FileExportSettings properties, Environment environment) { + FileserverActivationValidator.rejectAmbiguous(environment); return new LocalFilePublicationAdapter( new LocalFilePublicationPolicy( new FileDestinationId(properties.getDestinationId()), diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java similarity index 98% rename from src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java rename to src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java index a77b724..3d7fee2 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java @@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * inherit the defaults below. */ @ConfigurationProperties(prefix = "ca-skeleton.fileserver") -public class FileExportProperties { +public class FileExportSettings { /** * Whether to contribute the export adapter. Defaults to {@code false} so the module never diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java new file mode 100644 index 0000000..b3f87f2 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java @@ -0,0 +1,201 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** Restart-stable canonical identities for the R2 destination, schema, and format policies. */ +final class FilePublicationCanonicalDigests { + + static final String EFFECTIVE_POLICY_REVISION = "fileserver-effective-policy-v1"; + static final String FORMAT_ENCODER_REVISION = "csv-rfc4180-encoder-v1"; + + private static final HexFormat HEX = HexFormat.of(); + + private FilePublicationCanonicalDigests() {} + + static EffectivePolicyDescriptor effectivePolicyDescriptor( + FileDestinationId destinationId, + String providerId, + long maximumRows, + long maximumEncodedBytes, + PublicationGuarantee requiredPublicationGuarantee, + DurabilityGuarantee requiredDurabilityGuarantee) { + return new EffectivePolicyDescriptor( + destinationId, + providerId, + maximumRows, + maximumEncodedBytes, + requiredPublicationGuarantee, + requiredDurabilityGuarantee, + FORMAT_ENCODER_REVISION); + } + + static String effectivePolicyDigest(EffectivePolicyDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor must be non-null"); + Map fields = new LinkedHashMap<>(); + fields.put("destinationId", descriptor.destinationId().value()); + fields.put("providerId", descriptor.providerId()); + fields.put("maximumRows", Long.toString(descriptor.maximumRows())); + fields.put("maximumEncodedBytes", Long.toString(descriptor.maximumEncodedBytes())); + fields.put("requiredPublicationGuarantee", descriptor.requiredPublicationGuarantee().name()); + fields.put("requiredDurabilityGuarantee", descriptor.requiredDurabilityGuarantee().name()); + fields.put("formatEncoderRevision", descriptor.formatEncoderRevision()); + return digestNamedFields(fields); + } + + static CompiledIdentity compiledIdentity(EffectivePolicyDescriptor descriptor) { + String effectivePolicyDigest = effectivePolicyDigest(descriptor); + return compiledIdentity(effectivePolicyDigest); + } + + static CompiledIdentity compiledIdentity(String effectivePolicyDigest) { + FileserverR2Validation.requireSha256("effectivePolicyDigest", effectivePolicyDigest); + return new CompiledIdentity( + EFFECTIVE_POLICY_REVISION, + effectivePolicyDigest, + routeToken(effectivePolicyDigest), + formatPolicyDigest()); + } + + static String schemaDigest(ExportSchema schema) { + Objects.requireNonNull(schema, "schema must be non-null"); + Map fields = new LinkedHashMap<>(); + fields.put("schemaId", schema.schemaId()); + fields.put("schemaVersion", Integer.toString(schema.version())); + fields.put("columnCount", Integer.toString(schema.columns().size())); + for (int index = 0; index < schema.columns().size(); index++) { + Column column = schema.columns().get(index); + String prefix = "column." + String.format(Locale.ROOT, "%08d", index) + "."; + fields.put(prefix + "name", column.name()); + fields.put(prefix + "cellType", column.cellType().name()); + fields.put(prefix + "nullable", Boolean.toString(column.nullable())); + fields.put(prefix + "maximumUtf8Bytes", Integer.toString(column.maximumUtf8Bytes())); + fields.put(prefix + "formulaPolicy", column.formulaPolicy().name()); + } + return digestNamedFields(fields); + } + + static String formatPolicyDigest() { + Map fields = new LinkedHashMap<>(); + fields.put("formatEncoderRevision", FORMAT_ENCODER_REVISION); + fields.put("formatProfileId", "csv-rfc4180-v1"); + fields.put("charset", StandardCharsets.UTF_8.name()); + fields.put("delimiter", ","); + fields.put("quote", "\""); + fields.put("recordSeparator", "LF"); + fields.put("nullCell", "empty"); + fields.put("formulaMitigationPrefix", "'"); + return digestNamedFields(fields); + } + + static String routeToken(String effectivePolicyDigest) { + String validated = + FileserverR2Validation.requireSha256("effectivePolicyDigest", effectivePolicyDigest); + return "r" + validated.substring(0, 31); + } + + static String digestNamedFields(Map fields) { + Objects.requireNonNull(fields, "fields must be non-null"); + List orderedValues = new ArrayList<>(fields.size() * 2); + fields.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach( + entry -> { + orderedValues.add( + Objects.requireNonNull(entry.getKey(), "field name must be non-null")); + orderedValues.add( + Objects.requireNonNull(entry.getValue(), "field value must be non-null")); + }); + return digestOrderedValues(orderedValues); + } + + static String digestOrderedValues(List values) { + Objects.requireNonNull(values, "values must be non-null"); + MessageDigest digest = sha256(); + digest.update(intBytes(values.size())); + for (String value : values) { + byte[] encoded = + strictUtf8(Objects.requireNonNull(value, "canonical value must be non-null")); + digest.update(intBytes(encoded.length)); + digest.update(encoded); + } + return HEX.formatHex(digest.digest()); + } + + private static byte[] strictUtf8(String value) { + try { + ByteBuffer encoded = + StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + return bytes; + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException("canonical value must be valid UTF-8", exception); + } + } + + private static byte[] intBytes(int value) { + return ByteBuffer.allocate(Integer.BYTES).putInt(value).array(); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + record EffectivePolicyDescriptor( + FileDestinationId destinationId, + String providerId, + long maximumRows, + long maximumEncodedBytes, + PublicationGuarantee requiredPublicationGuarantee, + DurabilityGuarantee requiredDurabilityGuarantee, + String formatEncoderRevision) { + + EffectivePolicyDescriptor { + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + providerId = FileserverR2Validation.requireNormalizedId("providerId", providerId); + if (maximumRows < 1) { + throw new IllegalArgumentException("maximumRows must be positive"); + } + if (maximumEncodedBytes < 1) { + throw new IllegalArgumentException("maximumEncodedBytes must be positive"); + } + Objects.requireNonNull( + requiredPublicationGuarantee, "requiredPublicationGuarantee must be non-null"); + Objects.requireNonNull( + requiredDurabilityGuarantee, "requiredDurabilityGuarantee must be non-null"); + FileserverR2Validation.requireNonBlank("formatEncoderRevision", formatEncoderRevision); + } + } + + record CompiledIdentity( + String effectivePolicyRevision, + String effectivePolicyDigest, + String routeToken, + String formatPolicyDigest) {} +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java new file mode 100644 index 0000000..5423381 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.TabularRowProducer; + +/** + * Adapter-internal provider selected only after exact destination routing. + * + *

Provider types remain private to this outbound adapter and never cross the application port. + */ +interface FilePublicationProvider { + + FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer); +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java index a5e975b..129025a 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java @@ -3,6 +3,9 @@ package dev.caskeleton.adapter.outbound.fileserver; import dev.caskeleton.application.filepublication.ExportSchema.Column; import dev.caskeleton.application.filepublication.FilePublishRequest; import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -38,11 +41,28 @@ final class FilePublishRequestFingerprint { } private static void update(MessageDigest digest, String value) { - byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + byte[] bytes = strictUtf8(value); digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); digest.update(bytes); } + private static byte[] strictUtf8(String value) { + try { + ByteBuffer encoded = + StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + return bytes; + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException( + "file publication request contains malformed Unicode", exception); + } + } + private static void update(MessageDigest digest, int value) { digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); } diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java new file mode 100644 index 0000000..bf18682 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.env.Environment; + +/** Order-independent fail-fast validation for mutually exclusive R1 and R2 selectors. */ +final class FileserverActivationValidator { + + private FileserverActivationValidator() {} + + static void rejectAmbiguous(Environment environment) { + Binder binder = Binder.get(environment); + boolean legacy = + binder.bind("ca-skeleton.fileserver.enabled", Bindable.of(Boolean.class)).orElse(false); + boolean r2 = binder.bind("app.fileserver.enabled", Bindable.of(Boolean.class)).orElse(false); + if (legacy && r2) { + throw new IllegalStateException( + "ca-skeleton.fileserver.enabled and app.fileserver.enabled cannot both be true"); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java new file mode 100644 index 0000000..d6a8905 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java @@ -0,0 +1,227 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.adapter.outbound.fileserver.FileserverR2Settings.DestinationSettings; +import dev.caskeleton.adapter.outbound.fileserver.FileserverR2Settings.ProviderSettings; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Fail-closed compiler for exact R2 destination/provider bindings. */ +final class FileserverBindingCompiler { + + private static final String LOCAL_PERSISTENT = "local-persistent"; + private static final String UNIQUE_ATOMIC_CREATE = "unique-atomic-create"; + private static final String FILE_AND_DIRECTORY_SYNC = "file-and-directory-sync"; + + private FileserverBindingCompiler() {} + + static Map compile(FileserverR2Settings settings) { + Objects.requireNonNull(settings, "settings must be non-null"); + if (!settings.enabled()) { + return Map.of(); + } + if (settings.destinations().isEmpty()) { + throw new IllegalArgumentException( + "app.fileserver destinations must be explicitly configured when enabled"); + } + if (settings.providers().isEmpty()) { + throw new IllegalArgumentException( + "app.fileserver providers must be explicitly configured when enabled"); + } + + Map providers = validateProviders(settings.providers()); + rejectSharedRootAcrossProviderIds(providers); + Map destinations = normalizeDestinations(settings.destinations()); + Map compiled = new LinkedHashMap<>(); + Map policyDigests = new LinkedHashMap<>(); + destinations.forEach( + (destinationId, destination) -> { + String providerId = + FileserverR2Validation.normalizeId("provider-ref", destination.providerRef()); + ValidatedProvider provider = providers.get(providerId); + if (provider == null) { + throw new IllegalArgumentException( + "destination " + destinationId + " references unknown provider-ref " + providerId); + } + validateDestination(destinationId, destination); + FileDestinationId applicationDestinationId = new FileDestinationId(destinationId); + FilePublicationCanonicalDigests.EffectivePolicyDescriptor policyDescriptor = + FilePublicationCanonicalDigests.effectivePolicyDescriptor( + applicationDestinationId, + providerId, + destination.maximumRows(), + destination.maximumEncodedBytes(), + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + String effectivePolicyDigest = + FilePublicationCanonicalDigests.effectivePolicyDigest(policyDescriptor); + policyDigests.put(applicationDestinationId, effectivePolicyDigest); + FilePublicationCanonicalDigests.CompiledIdentity identity = + FilePublicationCanonicalDigests.compiledIdentity(effectivePolicyDigest); + compiled.put( + applicationDestinationId, + new CompiledFileDestination( + applicationDestinationId, + providerId, + provider.rootDirectory(), + destination.maximumRows(), + destination.maximumEncodedBytes(), + provider.settings().expectedFileStoreName(), + provider.settings().expectedFileStoreType(), + provider.settings().mountSentinelName(), + provider.settings().mountSentinelSha256(), + provider.settings().expectedOwner(), + provider.settings().maximumRootMode(), + provider.maximumRootPermissions(), + identity.effectivePolicyRevision(), + identity.effectivePolicyDigest(), + identity.routeToken(), + identity.formatPolicyDigest(), + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC)); + }); + Map routeTokens = deriveUniqueRouteTokens(policyDigests); + compiled.forEach( + (destinationId, destination) -> { + if (!destination.routeToken().equals(routeTokens.get(destinationId))) { + throw new IllegalStateException("compiled route token differs from route registry"); + } + }); + return Map.copyOf(compiled); + } + + static Map deriveUniqueRouteTokens( + Map policyDigests) { + Objects.requireNonNull(policyDigests, "policyDigests must be non-null"); + Map routeOwners = new LinkedHashMap<>(); + Map routeTokens = new LinkedHashMap<>(); + policyDigests.forEach( + (destinationId, policyDigest) -> { + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + String routeToken = FilePublicationCanonicalDigests.routeToken(policyDigest); + FileDestinationId existingRouteOwner = routeOwners.putIfAbsent(routeToken, destinationId); + if (existingRouteOwner != null) { + throw new IllegalArgumentException( + "compiled route token collision for " + + routeToken + + " between " + + existingRouteOwner.value() + + " and " + + destinationId.value()); + } + routeTokens.put(destinationId, routeToken); + }); + return Map.copyOf(routeTokens); + } + + private static Map validateProviders( + Map configuredProviders) { + Map providers = new LinkedHashMap<>(); + configuredProviders.forEach( + (configuredId, provider) -> { + String providerId = FileserverR2Validation.normalizeId("provider", configuredId); + if (providers.containsKey(providerId)) { + throw new IllegalArgumentException("duplicate normalized provider id: " + providerId); + } + providers.put(providerId, validateProvider(providerId, provider)); + }); + return providers; + } + + private static void rejectSharedRootAcrossProviderIds(Map providers) { + Map rootOwners = new LinkedHashMap<>(); + providers.forEach( + (providerId, provider) -> { + String existing = rootOwners.putIfAbsent(provider.rootDirectory(), providerId); + if (existing != null && !existing.equals(providerId)) { + throw new IllegalArgumentException( + "different fileserver provider IDs cannot share one root directory"); + } + }); + } + + private static Map normalizeDestinations( + Map configuredDestinations) { + Map destinations = new LinkedHashMap<>(); + configuredDestinations.forEach( + (configuredId, destination) -> { + String destinationId = FileserverR2Validation.normalizeId("destination", configuredId); + if (destinations.putIfAbsent(destinationId, destination) != null) { + throw new IllegalArgumentException( + "duplicate normalized destination id: " + destinationId); + } + }); + return destinations; + } + + private static ValidatedProvider validateProvider(String providerId, ProviderSettings provider) { + if (provider == null) { + throw new IllegalArgumentException("provider " + providerId + " settings must be non-null"); + } + if (!LOCAL_PERSISTENT.equals(provider.type())) { + throw new IllegalArgumentException( + "provider " + providerId + " type must be exactly local-persistent"); + } + if (provider.autoCreate()) { + throw new IllegalArgumentException("provider " + providerId + " auto-create must be false"); + } + if (!provider.strictPathSecurity()) { + throw new IllegalArgumentException( + "provider " + providerId + " strict-path-security must be true"); + } + + Path root = + FileserverR2Validation.requireAbsoluteNormalizedPath( + "provider " + providerId + " root-directory", provider.rootDirectory()); + FileserverR2Validation.requireNonBlank( + "expected-file-store-name attestation input", provider.expectedFileStoreName()); + FileserverR2Validation.requireNonBlank( + "expected-file-store-type attestation input", provider.expectedFileStoreType()); + FileserverR2Validation.requireNonBlank( + "expected-owner attestation input", provider.expectedOwner()); + FileserverR2Validation.requireSentinelName(provider.mountSentinelName()); + FileserverR2Validation.requireSha256( + "provider " + providerId + " mount-sentinel-sha256", provider.mountSentinelSha256()); + Set maximumPermissions = + FileserverR2Validation.parseMaximumRootMode(provider.maximumRootMode()); + return new ValidatedProvider(provider, root, maximumPermissions); + } + + private static void validateDestination(String destinationId, DestinationSettings destination) { + if (destination == null) { + throw new IllegalArgumentException( + "destination " + destinationId + " settings must be non-null"); + } + if (!UNIQUE_ATOMIC_CREATE.equals(destination.requiredPublication())) { + throw new IllegalArgumentException( + "destination " + + destinationId + + " required-publication must be exactly unique-atomic-create"); + } + if (!FILE_AND_DIRECTORY_SYNC.equals(destination.requiredDurability())) { + throw new IllegalArgumentException( + "destination " + + destinationId + + " required-durability must be exactly file-and-directory-sync"); + } + if (destination.maximumRows() < 1) { + throw new IllegalArgumentException( + "destination " + destinationId + " maximum-rows must be positive"); + } + if (destination.maximumEncodedBytes() < 1) { + throw new IllegalArgumentException( + "destination " + destinationId + " maximum-encoded-bytes must be positive"); + } + } + + private record ValidatedProvider( + ProviderSettings settings, + Path rootDirectory, + Set maximumRootPermissions) {} +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java new file mode 100644 index 0000000..b94e783 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java @@ -0,0 +1,855 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FileVersion; +import dev.caskeleton.application.filepublication.PublishedFileReference; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Strict bounded canonical JSON codec for the three private Fileserver R2 control records. */ +final class FileserverControlRecordCodec { + + private static final int MAXIMUM_RECORD_BYTES = 16_384; + private static final int MAXIMUM_RECEIPT_PAYLOAD_BYTES = 8_192; + private static final String RECEIPT_PREFIX = "rsv1."; + private static final String DIGEST_PATTERN = "[0-9a-f]{64}"; + private static final String FILE_ID_PATTERN = "[0-9a-f]{32}"; + private static final String ROUTE_PATTERN = "[a-z][a-z0-9]{5,31}"; + private static final String LOGICAL_ID_PATTERN = "[a-z][a-z0-9-]{0,62}"; + private static final String SEGMENT_PATTERN = "[A-Za-z0-9][A-Za-z0-9._-]{0,255}"; + private static final String FAILURE_CODE_PATTERN = "[A-Z][A-Z0-9_]{0,63}"; + + private static final List OPERATION_FIELDS = + List.of( + "schemaVersion", + "stateRevision", + "state", + "operationId", + "requestFingerprint", + "effectivePolicyRevision", + "effectivePolicyDigest", + "destinationId", + "providerId", + "fileId", + "routeToken", + "publishedFileName", + "stageFileName", + "byteSize", + "rowCount", + "columnCount", + "sha256", + "formulaMitigatedCount", + "manifestDigest", + "referenceDigest", + "createdAt", + "sealedAt", + "publishedAt", + "lastFailureCode", + "receiptSnapshot"); + + private static final List MANIFEST_FIELDS = + List.of( + "schemaVersion", + "operationId", + "fileId", + "providerId", + "fileReference", + "requestFingerprint", + "destinationId", + "schemaId", + "exportSchemaVersion", + "schemaDigest", + "formatProfileId", + "formatPolicyDigest", + "effectivePolicyRevision", + "effectivePolicyDigest", + "publishedFileName", + "fileVersion", + "mediaType", + "charset", + "byteSize", + "rowCount", + "columnCount", + "sha256", + "formulaMitigatedCount", + "publicationGuarantee", + "durabilityGuarantee", + "internalLocator", + "createdAt", + "publishedAt"); + + private static final List REFERENCE_FIELDS = + List.of( + "schemaVersion", + "fileId", + "routeToken", + "fileReference", + "operationId", + "fileVersion", + "manifestDigest", + "internalLocator", + "destinationId", + "providerId", + "publishedFileName", + "mediaType", + "charset", + "byteSize", + "sha256", + "publishedAt"); + + private static final List RECEIPT_FIELDS = + List.of( + "operationId", + "reference", + "destinationId", + "publishedFileName", + "fileVersion", + "formatProfileId", + "mediaType", + "charset", + "byteSize", + "dataRowCount", + "columnCount", + "sha256", + "publishedAt", + "publicationGuarantee", + "durabilityGuarantee", + "formulaMitigatedCount"); + + byte[] encodeOperation(DurablePublicationRecord record) { + if (record == null) { + throw new IllegalArgumentException("operation record must be non-null"); + } + JsonWriter json = new JsonWriter(); + json.number("schemaVersion", record.schemaVersion()); + json.number("stateRevision", record.stateRevision()); + json.string("state", record.state().name()); + json.string("operationId", record.operationId()); + json.string("requestFingerprint", record.requestFingerprint()); + json.string("effectivePolicyRevision", record.effectivePolicyRevision()); + json.string("effectivePolicyDigest", record.effectivePolicyDigest()); + json.string("destinationId", record.destinationId()); + json.string("providerId", record.providerId()); + json.string("fileId", record.fileId()); + json.string("routeToken", record.routeToken()); + json.string("publishedFileName", record.publishedFileName()); + json.string("stageFileName", record.stageFileName()); + json.number("byteSize", record.byteSize()); + json.number("rowCount", record.rowCount()); + json.number("columnCount", record.columnCount()); + json.string("sha256", record.sha256()); + json.number("formulaMitigatedCount", record.formulaMitigatedCount()); + json.string("manifestDigest", record.manifestDigest()); + json.string("referenceDigest", record.referenceDigest()); + json.string("createdAt", instant(record.createdAt())); + json.string("sealedAt", optionalInstant(record.sealedAt())); + json.string("publishedAt", optionalInstant(record.publishedAt())); + json.string("lastFailureCode", record.lastFailureCode()); + json.string("receiptSnapshot", record.receiptSnapshot()); + return json.bytes(); + } + + DurablePublicationRecord decodeOperation(byte[] bytes) { + Map values = parse(bytes, OPERATION_FIELDS); + DurablePublicationRecord record = + new DurablePublicationRecord( + integer(values, "schemaVersion"), + longValue(values, "stateRevision"), + DurablePublicationRecord.State.valueOf(string(values, "state")), + string(values, "operationId"), + string(values, "requestFingerprint"), + string(values, "effectivePolicyRevision"), + string(values, "effectivePolicyDigest"), + string(values, "destinationId"), + string(values, "providerId"), + string(values, "fileId"), + string(values, "routeToken"), + string(values, "publishedFileName"), + string(values, "stageFileName"), + longValue(values, "byteSize"), + longValue(values, "rowCount"), + integer(values, "columnCount"), + string(values, "sha256"), + longValue(values, "formulaMitigatedCount"), + string(values, "manifestDigest"), + string(values, "referenceDigest"), + requiredInstant(values, "createdAt"), + optionalInstant(values, "sealedAt"), + optionalInstant(values, "publishedAt"), + string(values, "lastFailureCode"), + string(values, "receiptSnapshot")); + requireCanonical(bytes, encodeOperation(record)); + return record; + } + + byte[] encodeManifest(PrivateFileManifest manifest) { + if (manifest == null) { + throw new IllegalArgumentException("manifest must be non-null"); + } + JsonWriter json = new JsonWriter(); + json.number("schemaVersion", manifest.schemaVersion()); + json.string("operationId", manifest.operationId()); + json.string("fileId", manifest.fileId()); + json.string("providerId", manifest.providerId()); + json.string("fileReference", manifest.fileReference()); + json.string("requestFingerprint", manifest.requestFingerprint()); + json.string("destinationId", manifest.destinationId()); + json.string("schemaId", manifest.schemaId()); + json.number("exportSchemaVersion", manifest.exportSchemaVersion()); + json.string("schemaDigest", manifest.schemaDigest()); + json.string("formatProfileId", manifest.formatProfileId()); + json.string("formatPolicyDigest", manifest.formatPolicyDigest()); + json.string("effectivePolicyRevision", manifest.effectivePolicyRevision()); + json.string("effectivePolicyDigest", manifest.effectivePolicyDigest()); + json.string("publishedFileName", manifest.publishedFileName()); + json.string("fileVersion", manifest.fileVersion()); + json.string("mediaType", manifest.mediaType()); + json.string("charset", manifest.charset()); + json.number("byteSize", manifest.byteSize()); + json.number("rowCount", manifest.rowCount()); + json.number("columnCount", manifest.columnCount()); + json.string("sha256", manifest.sha256()); + json.number("formulaMitigatedCount", manifest.formulaMitigatedCount()); + json.string("publicationGuarantee", manifest.publicationGuarantee().name()); + json.string("durabilityGuarantee", manifest.durabilityGuarantee().name()); + json.string("internalLocator", manifest.internalLocator()); + json.string("createdAt", instant(manifest.createdAt())); + json.string("publishedAt", instant(manifest.publishedAt())); + return json.bytes(); + } + + PrivateFileManifest decodeManifest(byte[] bytes) { + Map values = parse(bytes, MANIFEST_FIELDS); + PrivateFileManifest manifest = + new PrivateFileManifest( + integer(values, "schemaVersion"), + string(values, "operationId"), + string(values, "fileId"), + string(values, "providerId"), + string(values, "fileReference"), + string(values, "requestFingerprint"), + string(values, "destinationId"), + string(values, "schemaId"), + integer(values, "exportSchemaVersion"), + string(values, "schemaDigest"), + string(values, "formatProfileId"), + string(values, "formatPolicyDigest"), + string(values, "effectivePolicyRevision"), + string(values, "effectivePolicyDigest"), + string(values, "publishedFileName"), + string(values, "fileVersion"), + string(values, "mediaType"), + string(values, "charset"), + longValue(values, "byteSize"), + longValue(values, "rowCount"), + integer(values, "columnCount"), + string(values, "sha256"), + longValue(values, "formulaMitigatedCount"), + PublicationGuarantee.valueOf(string(values, "publicationGuarantee")), + DurabilityGuarantee.valueOf(string(values, "durabilityGuarantee")), + string(values, "internalLocator"), + requiredInstant(values, "createdAt"), + requiredInstant(values, "publishedAt")); + requireCanonical(bytes, encodeManifest(manifest)); + return manifest; + } + + byte[] encodeReference(PublishedReferenceRecord reference) { + if (reference == null) { + throw new IllegalArgumentException("reference record must be non-null"); + } + JsonWriter json = new JsonWriter(); + json.number("schemaVersion", reference.schemaVersion()); + json.string("fileId", reference.fileId()); + json.string("routeToken", reference.routeToken()); + json.string("fileReference", reference.fileReference()); + json.string("operationId", reference.operationId()); + json.string("fileVersion", reference.fileVersion()); + json.string("manifestDigest", reference.manifestDigest()); + json.string("internalLocator", reference.internalLocator()); + json.string("destinationId", reference.destinationId()); + json.string("providerId", reference.providerId()); + json.string("publishedFileName", reference.publishedFileName()); + json.string("mediaType", reference.mediaType()); + json.string("charset", reference.charset()); + json.number("byteSize", reference.byteSize()); + json.string("sha256", reference.sha256()); + json.string("publishedAt", instant(reference.publishedAt())); + return json.bytes(); + } + + PublishedReferenceRecord decodeReference(byte[] bytes) { + Map values = parse(bytes, REFERENCE_FIELDS); + PublishedReferenceRecord reference = + new PublishedReferenceRecord( + integer(values, "schemaVersion"), + string(values, "fileId"), + string(values, "routeToken"), + string(values, "fileReference"), + string(values, "operationId"), + string(values, "fileVersion"), + string(values, "manifestDigest"), + string(values, "internalLocator"), + string(values, "destinationId"), + string(values, "providerId"), + string(values, "publishedFileName"), + string(values, "mediaType"), + string(values, "charset"), + longValue(values, "byteSize"), + string(values, "sha256"), + requiredInstant(values, "publishedAt")); + requireCanonical(bytes, encodeReference(reference)); + return reference; + } + + String encodeReceiptSnapshot(FilePublishReceipt receipt) { + byte[] payload = encodeReceiptPayload(receipt); + if (payload.length > MAXIMUM_RECEIPT_PAYLOAD_BYTES) { + throw new IllegalArgumentException("receipt snapshot payload exceeds maximum size"); + } + return RECEIPT_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(payload); + } + + FilePublishReceipt decodeReceiptSnapshot(String snapshot) { + requireNoControl(snapshot, "receiptSnapshot", 12_288); + if (!snapshot.startsWith(RECEIPT_PREFIX) || snapshot.length() == RECEIPT_PREFIX.length()) { + throw new IllegalArgumentException("receipt snapshot has an unsupported format"); + } + String encoded = snapshot.substring(RECEIPT_PREFIX.length()); + if (!encoded.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException("receipt snapshot is not unpadded base64url"); + } + byte[] payload; + try { + payload = Base64.getUrlDecoder().decode(encoded); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("receipt snapshot payload is malformed", exception); + } + if (payload.length > MAXIMUM_RECEIPT_PAYLOAD_BYTES + || !Base64.getUrlEncoder().withoutPadding().encodeToString(payload).equals(encoded)) { + throw new IllegalArgumentException("receipt snapshot payload is not canonical"); + } + + Map values = parse(payload, RECEIPT_FIELDS); + FilePublishReceipt receipt = + new FilePublishReceipt( + new FilePublishOperationId(string(values, "operationId")), + new PublishedFileReference(string(values, "reference")), + new FileDestinationId(string(values, "destinationId")), + string(values, "publishedFileName"), + new FileVersion(string(values, "fileVersion")), + string(values, "formatProfileId"), + string(values, "mediaType"), + string(values, "charset"), + longValue(values, "byteSize"), + longValue(values, "dataRowCount"), + integer(values, "columnCount"), + string(values, "sha256"), + requiredInstant(values, "publishedAt"), + PublicationGuarantee.valueOf(string(values, "publicationGuarantee")), + DurabilityGuarantee.valueOf(string(values, "durabilityGuarantee")), + longValue(values, "formulaMitigatedCount")); + requireCanonical(payload, encodeReceiptPayload(receipt)); + return receipt; + } + + private byte[] encodeReceiptPayload(FilePublishReceipt receipt) { + validateReceipt(receipt); + JsonWriter json = new JsonWriter(); + json.string("operationId", receipt.operationId().value()); + json.string("reference", receipt.reference().value()); + json.string("destinationId", receipt.destinationId().value()); + json.string("publishedFileName", receipt.publishedFileName()); + json.string("fileVersion", receipt.version().value()); + json.string("formatProfileId", receipt.formatProfileId()); + json.string("mediaType", receipt.mediaType()); + json.string("charset", receipt.charset()); + json.number("byteSize", receipt.byteSize()); + json.number("dataRowCount", receipt.dataRowCount()); + json.number("columnCount", receipt.columnCount()); + json.string("sha256", receipt.sha256()); + json.string("publishedAt", instant(receipt.publishedAt())); + json.string("publicationGuarantee", receipt.publicationGuarantee().name()); + json.string("durabilityGuarantee", receipt.durabilityGuarantee().name()); + json.number("formulaMitigatedCount", receipt.formulaMitigatedCount()); + return json.bytes(); + } + + private static void validateReceipt(FilePublishReceipt receipt) { + if (receipt == null) { + throw new IllegalArgumentException("receipt must be non-null"); + } + requireText(receipt.operationId().value(), "receipt.operationId", 128); + requireReferenceMatches(receipt.reference().value(), null, null); + requireLogicalId(receipt.destinationId().value(), "receipt.destinationId"); + requireSegment(receipt.publishedFileName(), "receipt.publishedFileName"); + requireText(receipt.version().value(), "receipt.fileVersion", 128); + requireText(receipt.formatProfileId(), "receipt.formatProfileId", 128); + requireText(receipt.mediaType(), "receipt.mediaType", 128); + requireText(receipt.charset(), "receipt.charset", 64); + if (receipt.byteSize() < 0 + || receipt.dataRowCount() < 0 + || receipt.columnCount() < 1 + || receipt.formulaMitigatedCount() < 0) { + throw new IllegalArgumentException("receipt sizes and counts are out of range"); + } + requireFormulaCountWithinCells( + receipt.dataRowCount(), receipt.columnCount(), receipt.formulaMitigatedCount(), "receipt"); + requireDigest(receipt.sha256(), "receipt.sha256"); + requireInstant(receipt.publishedAt(), "receipt.publishedAt"); + } + + static void validateReceiptSnapshot( + String snapshot, + String operationId, + String destinationId, + String publishedFileName, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + Instant publishedAt, + String routeToken, + String fileId) { + FilePublishReceipt receipt = new FileserverControlRecordCodec().decodeReceiptSnapshot(snapshot); + if (!receipt.operationId().value().equals(operationId) + || !receipt.destinationId().value().equals(destinationId) + || !receipt.publishedFileName().equals(publishedFileName) + || receipt.byteSize() != byteSize + || receipt.dataRowCount() != rowCount + || receipt.columnCount() != columnCount + || !receipt.sha256().equals(sha256) + || receipt.formulaMitigatedCount() != formulaMitigatedCount + || !receipt.publishedAt().equals(publishedAt)) { + throw new IllegalArgumentException("receipt snapshot does not match operation record"); + } + requireReferenceMatches(receipt.reference().value(), fileId, routeToken); + } + + static void requireReferenceMatches( + String reference, String expectedFileId, String expectedRouteToken) { + requireNoControl(reference, "fileReference", 256); + String[] segments = reference.split("\\.", -1); + if (segments.length != 4) { + throw new IllegalArgumentException("fileReference is malformed"); + } + String routeToken = expectedRouteToken == null ? segments[1] : expectedRouteToken; + R2PublishedReferenceCodec.DecodedReference decoded = + new R2PublishedReferenceCodec() + .decode(new PublishedFileReference(reference), Set.of(routeToken)); + if (expectedFileId != null && !expectedFileId.equals(decoded.fileId())) { + throw new IllegalArgumentException("fileReference does not match fileId"); + } + if (expectedRouteToken != null && !expectedRouteToken.equals(decoded.routeToken())) { + throw new IllegalArgumentException("fileReference does not match routeToken"); + } + } + + static void requireDigest(String value, String field) { + if (value == null || !value.matches(DIGEST_PATTERN)) { + throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest"); + } + } + + static void requireOptionalDigest(String value, String field) { + if (value == null || (!value.isEmpty() && !value.matches(DIGEST_PATTERN))) { + throw new IllegalArgumentException(field + " must be blank or a lowercase SHA-256 digest"); + } + } + + static void requireFileId(String value) { + if (value == null || !value.matches(FILE_ID_PATTERN)) { + throw new IllegalArgumentException( + "fileId must be exactly 32 lowercase hexadecimal characters"); + } + } + + static void requireRouteToken(String value) { + if (value == null || !value.matches(ROUTE_PATTERN)) { + throw new IllegalArgumentException("routeToken must match " + ROUTE_PATTERN); + } + } + + static void requireLogicalId(String value, String field) { + if (value == null || !value.matches(LOGICAL_ID_PATTERN)) { + throw new IllegalArgumentException(field + " must match " + LOGICAL_ID_PATTERN); + } + } + + static void requireSegment(String value, String field) { + if (value == null + || !value.matches(SEGMENT_PATTERN) + || value.equals(".") + || value.equals("..") + || value.contains("/") + || value.contains("\\") + || value.contains(":")) { + throw new IllegalArgumentException(field + " must be a safe single segment"); + } + } + + static void requireText(String value, String field, int maximumLength) { + if (value == null || value.isBlank() || value.length() > maximumLength) { + throw new IllegalArgumentException(field + " is invalid"); + } + requireNoControl(value, field, maximumLength); + } + + static void requireNoControl(String value, String field, int maximumLength) { + if (value == null || value.length() > maximumLength) { + throw new IllegalArgumentException(field + " is invalid"); + } + requireWellFormedUnicode(value, field); + if (value.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " contains a control character"); + } + } + + static void requireOptionalFailureCode(String value) { + if (value == null || (!value.isEmpty() && !value.matches(FAILURE_CODE_PATTERN))) { + throw new IllegalArgumentException("lastFailureCode is invalid"); + } + } + + static void requireFormulaCountWithinCells( + long rowCount, int columnCount, long formulaMitigatedCount, String recordType) { + if (formulaMitigatedCount == 0) { + return; + } + if (rowCount == 0 || columnCount == 0) { + throw new IllegalArgumentException( + recordType + " formulaMitigatedCount requires at least one cell"); + } + if (rowCount <= Long.MAX_VALUE / columnCount + && formulaMitigatedCount > rowCount * columnCount) { + throw new IllegalArgumentException( + recordType + " formulaMitigatedCount exceeds the number of cells"); + } + } + + static void requireInstant(Instant value, String field) { + if (value == null) { + throw new IllegalArgumentException(field + " must be a UTC Instant"); + } + } + + static void requireOrderedInstant(Instant earlier, Instant later, String field) { + requireInstant(later, field); + if (later.isBefore(earlier)) { + throw new IllegalArgumentException(field + " precedes the prior timestamp"); + } + } + + static boolean allBlank(String... values) { + for (String value : values) { + if (value == null || !value.isEmpty()) { + return false; + } + } + return true; + } + + private static Map parse(byte[] bytes, List fields) { + return new FlatJsonParser(bytes, fields).parse(); + } + + private static String string(Map values, String field) { + JsonValue value = values.get(field); + if (value == null || !value.string()) { + throw new IllegalArgumentException(field + " must be a JSON string"); + } + return value.value(); + } + + private static long longValue(Map values, String field) { + JsonValue value = values.get(field); + if (value == null || value.string()) { + throw new IllegalArgumentException(field + " must be a JSON integer"); + } + try { + return Long.parseLong(value.value()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(field + " is outside the integer range", exception); + } + } + + private static int integer(Map values, String field) { + long value = longValue(values, field); + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException(field + " is outside the integer range"); + } + return (int) value; + } + + private static Instant requiredInstant(Map values, String field) { + String value = string(values, field); + if (value.isEmpty()) { + throw new IllegalArgumentException(field + " must be present"); + } + return parseInstant(value, field); + } + + private static Instant optionalInstant(Map values, String field) { + String value = string(values, field); + return value.isEmpty() ? null : parseInstant(value, field); + } + + private static Instant parseInstant(String value, String field) { + try { + Instant result = Instant.parse(value); + if (!result.toString().equals(value)) { + throw new IllegalArgumentException(field + " is not a canonical UTC Instant"); + } + return result; + } catch (RuntimeException exception) { + throw new IllegalArgumentException(field + " is not a canonical UTC Instant", exception); + } + } + + private static String instant(Instant value) { + requireInstant(value, "instant"); + return value.toString(); + } + + private static String optionalInstant(Instant value) { + return value == null ? "" : value.toString(); + } + + private static void requireCanonical(byte[] supplied, byte[] canonical) { + if (!Arrays.equals(supplied, canonical)) { + throw new IllegalArgumentException("control record is not canonical JSON"); + } + } + + private static void requireWellFormedUnicode(String value, String field) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (Character.isHighSurrogate(character)) { + if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) { + throw new IllegalArgumentException(field + " contains an unpaired surrogate"); + } + index++; + } else if (Character.isLowSurrogate(character)) { + throw new IllegalArgumentException(field + " contains an unpaired surrogate"); + } + } + } + + private record JsonValue(String value, boolean string) {} + + private static final class JsonWriter { + + private final StringBuilder target = new StringBuilder(1024); + private boolean first = true; + + private JsonWriter() { + target.append('{'); + } + + private void string(String key, String value) { + prefix(key); + appendQuoted(target, value); + } + + private void number(String key, long value) { + prefix(key); + target.append(value); + } + + private void prefix(String key) { + if (!first) { + target.append(','); + } + first = false; + appendQuoted(target, key); + target.append(':'); + } + + private byte[] bytes() { + byte[] result = target.append('}').toString().getBytes(StandardCharsets.UTF_8); + if (result.length > MAXIMUM_RECORD_BYTES) { + throw new IllegalArgumentException("control record exceeds maximum size"); + } + return result; + } + } + + private static void appendQuoted(StringBuilder target, String value) { + requireNoControl(value, "JSON string", MAXIMUM_RECORD_BYTES); + target.append('"'); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character == '"') { + target.append("\\\""); + } else if (character == '\\') { + target.append("\\\\"); + } else { + target.append(character); + } + } + target.append('"'); + } + + private static final class FlatJsonParser { + + private final String input; + private final Set allowedFields; + private int cursor; + + private FlatJsonParser(byte[] bytes, List fields) { + if (bytes == null || bytes.length < 2 || bytes.length > MAXIMUM_RECORD_BYTES) { + throw new IllegalArgumentException("control record size is out of bounds"); + } + input = decodeUtf8(bytes); + allowedFields = new LinkedHashSet<>(fields); + } + + private Map parse() { + Map values = new LinkedHashMap<>(); + whitespace(); + expect('{'); + whitespace(); + if (!peek('}')) { + while (true) { + String key = quoted(); + if (!allowedFields.contains(key)) { + throw new IllegalArgumentException("unknown control record field"); + } + whitespace(); + expect(':'); + whitespace(); + JsonValue value = + peek('"') ? new JsonValue(quoted(), true) : new JsonValue(number(), false); + if (values.putIfAbsent(key, value) != null) { + throw new IllegalArgumentException("duplicate control record field"); + } + whitespace(); + if (!peek(',')) { + break; + } + cursor++; + whitespace(); + } + } + expect('}'); + whitespace(); + if (cursor != input.length()) { + throw new IllegalArgumentException("trailing control record content"); + } + if (!values.keySet().equals(allowedFields)) { + throw new IllegalArgumentException("control record fields do not match schema"); + } + return values; + } + + private String quoted() { + expect('"'); + StringBuilder value = new StringBuilder(); + while (cursor < input.length()) { + char character = input.charAt(cursor++); + if (character == '"') { + String result = value.toString(); + requireNoControl(result, "JSON string", MAXIMUM_RECORD_BYTES); + return result; + } + if (character < 0x20) { + throw new IllegalArgumentException("unescaped JSON control character"); + } + if (character != '\\') { + value.append(character); + continue; + } + if (cursor >= input.length()) { + throw new IllegalArgumentException("truncated JSON escape"); + } + char escape = input.charAt(cursor++); + switch (escape) { + case '"' -> value.append('"'); + case '\\' -> value.append('\\'); + case '/' -> value.append('/'); + case 'u' -> value.append(unicode()); + case 'b', 'f', 'n', 'r', 't' -> + throw new IllegalArgumentException("control characters are forbidden"); + default -> throw new IllegalArgumentException("invalid JSON escape"); + } + } + throw new IllegalArgumentException("unterminated JSON string"); + } + + private char unicode() { + if (cursor + 4 > input.length()) { + throw new IllegalArgumentException("truncated JSON unicode escape"); + } + String digits = input.substring(cursor, cursor + 4); + if (!digits.matches("[0-9A-Fa-f]{4}")) { + throw new IllegalArgumentException("invalid JSON unicode escape"); + } + cursor += 4; + return (char) Integer.parseInt(digits, 16); + } + + private String number() { + int start = cursor; + if (peek('-')) { + cursor++; + } + int digits = cursor; + while (cursor < input.length()) { + char character = input.charAt(cursor); + if (character < '0' || character > '9') { + break; + } + cursor++; + } + if (cursor == digits) { + throw new IllegalArgumentException("invalid JSON integer"); + } + return input.substring(start, cursor); + } + + private void whitespace() { + while (cursor < input.length() && Character.isWhitespace(input.charAt(cursor))) { + cursor++; + } + } + + private boolean peek(char expected) { + return cursor < input.length() && input.charAt(cursor) == expected; + } + + private void expect(char expected) { + if (!peek(expected)) { + throw new IllegalArgumentException("unexpected control record token"); + } + cursor++; + } + + private static String decodeUtf8(byte[] bytes) { + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException("control record is not valid UTF-8", exception); + } + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java new file mode 100644 index 0000000..c8413a3 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationPort; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Clock; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** Fail-closed Spring composition for the explicit Fileserver R2 destination registry. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(FileserverR2Settings.class) +public class FileserverR2Config { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + + @Bean + @ConditionalOnProperty(prefix = "app.fileserver", name = "enabled", havingValue = "true") + public FilePublicationPort routingFilePublicationPort( + FileserverR2Settings settings, Environment environment) { + FileserverActivationValidator.rejectAmbiguous(environment); + Map destinations = + FileserverBindingCompiler.compile(settings); + rejectSharedRootAcrossProviderIds(destinations); + + Map> byProvider = new LinkedHashMap<>(); + destinations.values().stream() + .sorted(java.util.Comparator.comparing(destination -> destination.destinationId().value())) + .forEach( + destination -> + byProvider + .computeIfAbsent(destination.providerId(), ignored -> new ArrayList<>()) + .add(destination)); + + Map routes = new LinkedHashMap<>(); + for (List providerDestinations : byProvider.values()) { + CompiledFileDestination first = providerDestinations.getFirst(); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(first); + LocalPersistentControlPlane controlPlane = + new LocalPersistentControlPlane(attestor, evidence); + LocalPersistentPayloadOperations payload = + new LocalPersistentPayloadOperations(attestor, evidence); + Map runtimes = + new LinkedHashMap<>(); + providerDestinations.forEach( + destination -> + runtimes.put( + destination.destinationId(), + new LocalPersistentPublicationProvider.DestinationRuntime( + destination, controlPlane, payload))); + LocalPersistentPublicationProvider provider = + new LocalPersistentPublicationProvider( + runtimes, Clock.systemUTC(), FileserverR2Config::randomFileId); + providerDestinations.forEach( + destination -> routes.put(destination.destinationId(), provider)); + } + return new RoutingFilePublicationAdapter(routes); + } + + private static void rejectSharedRootAcrossProviderIds( + Map destinations) { + Map rootOwners = new LinkedHashMap<>(); + destinations + .values() + .forEach( + destination -> { + String existing = + rootOwners.putIfAbsent(destination.rootDirectory(), destination.providerId()); + if (existing != null && !existing.equals(destination.providerId())) { + throw new IllegalArgumentException( + "different fileserver provider IDs cannot share one root directory"); + } + }); + } + + private static String randomFileId() { + byte[] bytes = new byte[16]; + SECURE_RANDOM.nextBytes(bytes); + return HEX.formatHex(bytes); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java new file mode 100644 index 0000000..5ed171f --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Settings.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Typed, opt-in settings for exact R2 destination-to-provider bindings. */ +@ConfigurationProperties(prefix = "app.fileserver", ignoreUnknownFields = false) +public record FileserverR2Settings( + boolean enabled, + Map destinations, + Map providers) { + + public FileserverR2Settings { + destinations = destinations == null ? Map.of() : Map.copyOf(destinations); + providers = providers == null ? Map.of() : Map.copyOf(providers); + } + + /** Required publication contract and bounds for one logical application destination. */ + public record DestinationSettings( + String providerRef, + String requiredPublication, + String requiredDurability, + long maximumRows, + long maximumEncodedBytes) {} + + /** + * Provider-specific root and startup-attestation inputs. The sentinel name is one control-free + * path segment whose UTF-8 encoding is at most 255 bytes. + */ + public record ProviderSettings( + String type, + String rootDirectory, + boolean autoCreate, + boolean strictPathSecurity, + String expectedFileStoreName, + String expectedFileStoreType, + String mountSentinelName, + String mountSentinelSha256, + String expectedOwner, + String maximumRootMode) {} +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java new file mode 100644 index 0000000..42d188e --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** Shared fail-closed value validation for R2 settings and compiled descriptors. */ +final class FileserverR2Validation { + + private static final String ID_PATTERN = "[a-z][a-z0-9-]{0,62}"; + private static final String SHA256_PATTERN = "[0-9a-f]{64}"; + private static final String POSIX_MODE_PATTERN = "0[0-7]{3}"; + private static final int MAXIMUM_FILE_NAME_UTF8_BYTES = 255; + + private FileserverR2Validation() {} + + static String normalizeId(String label, String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(label + " id must be non-blank"); + } + String normalized = value.trim(); + if (!normalized.matches(ID_PATTERN)) { + throw new IllegalArgumentException( + label + " id must match [a-z][a-z0-9-]{0,62} after trimming"); + } + return normalized; + } + + static String requireNormalizedId(String label, String value) { + String normalized = normalizeId(label, value); + if (!normalized.equals(value)) { + throw new IllegalArgumentException(label + " must already be normalized"); + } + return normalized; + } + + static String requireNonBlank(String label, String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must be non-blank"); + } + return value; + } + + static Path requireAbsoluteNormalizedPath(String label, String configuredPath) { + String value = requireNonBlank(label, configuredPath); + try { + return requireAbsoluteNormalizedPath(label, Path.of(value)); + } catch (InvalidPathException exception) { + throw new IllegalArgumentException(label + " must be a valid path", exception); + } + } + + static Path requireAbsoluteNormalizedPath(String label, Path path) { + Objects.requireNonNull(path, label + " must be non-null"); + if (!path.isAbsolute()) { + throw new IllegalArgumentException(label + " must be absolute"); + } + Path normalized = path.normalize(); + if (!path.equals(normalized)) { + throw new IllegalArgumentException(label + " must already be normalized"); + } + return normalized; + } + + static String requireSentinelName(String value) { + requireNonBlank("mount-sentinel-name", value); + if (".".equals(value) + || "..".equals(value) + || value.contains("/") + || value.contains("\\") + || value.chars().anyMatch(Character::isISOControl)) { + throw invalidSentinelName(); + } + + try { + int encodedLength = + StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)) + .remaining(); + if (encodedLength > MAXIMUM_FILE_NAME_UTF8_BYTES) { + throw invalidSentinelName(); + } + } catch (CharacterCodingException exception) { + throw invalidSentinelName(exception); + } + + try { + Path sentinel = Path.of(value); + if (sentinel.getNameCount() != 1 + || sentinel.getFileName() == null + || !value.equals(sentinel.getFileName().toString())) { + throw invalidSentinelName(); + } + } catch (InvalidPathException exception) { + throw invalidSentinelName(exception); + } + return value; + } + + static String requireSha256(String label, String value) { + if (value == null || !value.matches(SHA256_PATTERN)) { + throw new IllegalArgumentException(label + " must be a lowercase SHA-256 digest"); + } + return value; + } + + static Set parseMaximumRootMode(String mode) { + if (mode == null || !mode.matches(POSIX_MODE_PATTERN)) { + throw new IllegalArgumentException( + "maximum-root-mode must be exactly four octal digits such as 0750"); + } + int owner = mode.charAt(1) - '0'; + int group = mode.charAt(2) - '0'; + int others = mode.charAt(3) - '0'; + if ((group & 2) != 0 || (others & 2) != 0) { + throw new IllegalArgumentException("maximum-root-mode must not allow group or world write"); + } + + EnumSet permissions = EnumSet.noneOf(PosixFilePermission.class); + addPermissions( + permissions, + owner, + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE); + addPermissions( + permissions, + group, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_WRITE, + PosixFilePermission.GROUP_EXECUTE); + addPermissions( + permissions, + others, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_WRITE, + PosixFilePermission.OTHERS_EXECUTE); + return Set.copyOf(permissions); + } + + static Set requireMatchingMaximumRootPermissions( + String mode, Set permissions) { + Set immutablePermissions = + Set.copyOf(Objects.requireNonNull(permissions, "maximumRootPermissions must be non-null")); + if (!immutablePermissions.equals(parseMaximumRootMode(mode))) { + throw new IllegalArgumentException( + "maximumRootPermissions must exactly match maximumRootMode"); + } + return immutablePermissions; + } + + private static IllegalArgumentException invalidSentinelName() { + return new IllegalArgumentException( + "mount-sentinel-name must be a valid control-free single path name of at most 255 UTF-8 bytes"); + } + + private static IllegalArgumentException invalidSentinelName(Exception cause) { + return new IllegalArgumentException( + "mount-sentinel-name must be a valid control-free single path name of at most 255 UTF-8 bytes", + cause); + } + + private static void addPermissions( + Set permissions, + int mode, + PosixFilePermission read, + PosixFilePermission write, + PosixFilePermission execute) { + if ((mode & 4) != 0) { + permissions.add(read); + } + if ((mode & 2) != 0) { + permissions.add(write); + } + if ((mode & 1) != 0) { + permissions.add(execute); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java new file mode 100644 index 0000000..29527c1 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java @@ -0,0 +1,1467 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +/** Forced local storage for canonical Fileserver R2 control records. */ +final class LocalPersistentControlPlane { + + private static final int MAXIMUM_RECORD_BYTES = 16_384; + private static final int OPERATION_LOCK_STRIPE_COUNT = 256; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + private static final ReentrantLock[] OPERATION_LOCK_STRIPES = createOperationLockStripes(); + private static final ReentrantLock[] IMMUTABLE_LOCK_STRIPES = createOperationLockStripes(); + private static final ThreadLocal> HELD_OPERATION_TOKENS = new ThreadLocal<>(); + private static final Set POISONED_OPERATION_LOCK_ROOTS = ConcurrentHashMap.newKeySet(); + private static final Set TEMPORARY_WRITE_OPTIONS = + Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + + private final LocalPersistentRootAttestor attestor; + private final LocalPersistentRootEvidence evidence; + private final FileserverControlRecordCodec codec; + private final ContextualFaultCallback faultCallback; + private final SecureRecordOperations secureRecordOperations; + private final IdentityVerifier identityVerifier; + private final LockLifecycle lockLifecycle; + private final String operationLockRootKey; + private final Path operationsDirectory; + private final Path manifestsDirectory; + private final Path referencesDirectory; + + LocalPersistentControlPlane( + LocalPersistentRootAttestor attestor, LocalPersistentRootEvidence evidence) { + this( + attestor, + evidence, + new FileserverControlRecordCodec(), + context -> {}, + systemSecureRecordOperations(), + null, + systemLockLifecycle()); + } + + LocalPersistentControlPlane( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + FaultCallback faultCallback) { + this( + attestor, + evidence, + new FileserverControlRecordCodec(), + contextual(faultCallback), + systemSecureRecordOperations(), + null, + systemLockLifecycle()); + } + + LocalPersistentControlPlane( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + FileserverControlRecordCodec codec) { + this( + attestor, + evidence, + codec, + context -> {}, + systemSecureRecordOperations(), + null, + systemLockLifecycle()); + } + + LocalPersistentControlPlane( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + FaultCallback faultCallback, + SecureRecordOperations secureRecordOperations, + IdentityVerifier identityVerifier) { + this( + attestor, + evidence, + new FileserverControlRecordCodec(), + contextual(faultCallback), + secureRecordOperations, + identityVerifier, + systemLockLifecycle()); + } + + LocalPersistentControlPlane( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + FaultCallback faultCallback, + SecureRecordOperations secureRecordOperations, + IdentityVerifier identityVerifier, + LockLifecycle lockLifecycle) { + this( + attestor, + evidence, + new FileserverControlRecordCodec(), + contextual(faultCallback), + secureRecordOperations, + identityVerifier, + lockLifecycle); + } + + static LocalPersistentControlPlane withContextualFaultCallback( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + ContextualFaultCallback faultCallback) { + return new LocalPersistentControlPlane( + attestor, + evidence, + new FileserverControlRecordCodec(), + faultCallback, + systemSecureRecordOperations(), + null, + systemLockLifecycle()); + } + + private LocalPersistentControlPlane( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + FileserverControlRecordCodec codec, + ContextualFaultCallback faultCallback, + SecureRecordOperations secureRecordOperations, + IdentityVerifier identityVerifier, + LockLifecycle lockLifecycle) { + this.attestor = Objects.requireNonNull(attestor, "attestor must be non-null"); + this.evidence = Objects.requireNonNull(evidence, "evidence must be non-null"); + this.codec = Objects.requireNonNull(codec, "codec must be non-null"); + this.faultCallback = Objects.requireNonNull(faultCallback, "faultCallback must be non-null"); + this.secureRecordOperations = + Objects.requireNonNull(secureRecordOperations, "secureRecordOperations must be non-null"); + this.identityVerifier = + identityVerifier == null + ? () -> this.attestor.verifyIdentity(this.evidence) + : identityVerifier; + this.lockLifecycle = Objects.requireNonNull(lockLifecycle, "lockLifecycle must be non-null"); + operationLockRootKey = operationLockRootKey(evidence); + Path controlDirectory = evidence.root().resolve(".ca-fileserver"); + operationsDirectory = controlDirectory.resolve("operations"); + manifestsDirectory = controlDirectory.resolve("manifests"); + referencesDirectory = controlDirectory.resolve("references"); + verifyAttestedIdentity(); + } + + Path root() { + return evidence.root(); + } + + void storeOperation(DurablePublicationRecord record) { + Objects.requireNonNull(record, "record must be non-null"); + storeOperation(record.operationId(), record); + } + + void storeOperation(String operationId, DurablePublicationRecord record) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + Objects.requireNonNull(record, "record must be non-null"); + requireOperationLockRootHealthy(); + if (!operationId.equals(record.operationId())) { + throw conflict("operationId cannot change"); + } + Path target = operationPath(operationId); + String token = sha256(operationId); + String scopedLockKey = scopedOperationLockKey(token); + Set heldTokens = HELD_OPERATION_TOKENS.get(); + if (heldTokens != null && heldTokens.contains(scopedLockKey)) { + storeOperationUpdate(target, record); + return; + } + try (OperationLock ignored = acquireOperationLock(operationId)) { + storeOperationUpdate(target, record); + } + } + + OperationLock acquireOperationLock(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + String token = sha256(operationId); + String scopedLockKey = scopedOperationLockKey(token); + Path lockPath = operationsDirectory.resolve(token.substring(0, 2)).resolve(token + ".lock"); + ReentrantLock jvmLock = + OPERATION_LOCK_STRIPES[ + Math.floorMod(scopedLockKey.hashCode(), OPERATION_LOCK_STRIPE_COUNT)]; + requireOperationLockRootHealthy(); + jvmLock.lock(); + FileChannel channel = null; + FileLock fileLock = null; + try { + requireOperationLockRootHealthy(); + verifyAttestedIdentity(); + preparePrivateShard(lockPath.getParent(), true); + verifyAttestedIdentity(); + channel = + FileChannel.open( + lockPath, + Set.of( + StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + validateOperationLockFile(lockPath); + fileLock = channel.lock(); + verifyAttestedIdentity(); + requireOperationLockRootHealthy(); + Set heldTokens = HELD_OPERATION_TOKENS.get(); + if (heldTokens == null) { + heldTokens = new java.util.HashSet<>(); + HELD_OPERATION_TOKENS.set(heldTokens); + } + heldTokens.add(scopedLockKey); + return new OperationLock( + operationLockRootKey, + scopedLockKey, + Thread.currentThread(), + jvmLock, + channel, + fileLock, + lockLifecycle); + } catch (IOException | RuntimeException exception) { + closeLockAfterAcquireFailure(fileLock, channel, exception); + jvmLock.unlock(); + if (exception instanceof LocalPersistentControlPlaneException controlPlaneException) { + throw controlPlaneException; + } + throw storage("operation lock cannot be acquired", exception); + } + } + + boolean operationLockHeldByCurrentThread(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + Set heldTokens = HELD_OPERATION_TOKENS.get(); + return heldTokens != null && heldTokens.contains(scopedOperationLockKey(sha256(operationId))); + } + + boolean operationLockStripeHeldByCurrentThread(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + String scopedLockKey = scopedOperationLockKey(sha256(operationId)); + return OPERATION_LOCK_STRIPES[ + Math.floorMod(scopedLockKey.hashCode(), OPERATION_LOCK_STRIPE_COUNT)] + .isHeldByCurrentThread(); + } + + Optional findOperation(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + Optional result = + find(operationPath(operationId), codec::decodeOperation); + result.ifPresent( + record -> { + if (!record.operationId().equals(operationId)) { + throw integrity("operation control record identity mismatch"); + } + }); + return result; + } + + Optional findStoredOperation(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + Optional result = + find(operationPath(operationId), this::decodeStoredOperation); + result.ifPresent( + record -> { + if (!record.operationId().equals(operationId)) { + throw integrity("operation control record identity mismatch"); + } + }); + return result; + } + + void storeManifest(PrivateFileManifest manifest) { + Objects.requireNonNull(manifest, "manifest must be non-null"); + requireOperationLockRootHealthy(); + Path target = fileRecordPath(manifestsDirectory, manifest.fileId()); + withImmutableLock( + "manifest:" + manifest.fileId(), + () -> + storeImmutable( + target, + codec.encodeManifest(manifest), + codec::decodeManifest, + manifest, + existing -> existing.fileId().equals(manifest.fileId()))); + } + + Optional findManifest(String fileId) { + FileserverControlRecordCodec.requireFileId(fileId); + Optional result = + find(fileRecordPath(manifestsDirectory, fileId), codec::decodeManifest); + result.ifPresent( + manifest -> { + if (!manifest.fileId().equals(fileId)) { + throw integrity("manifest control record identity mismatch"); + } + }); + return result; + } + + void storeReference(PublishedReferenceRecord reference) { + Objects.requireNonNull(reference, "reference must be non-null"); + requireOperationLockRootHealthy(); + Path target = fileRecordPath(referencesDirectory, reference.fileId()); + withImmutableLock( + "reference:" + reference.fileId(), + () -> + storeImmutable( + target, + codec.encodeReference(reference), + codec::decodeReference, + reference, + existing -> existing.fileId().equals(reference.fileId()))); + } + + Optional findReference(String fileId) { + FileserverControlRecordCodec.requireFileId(fileId); + Optional result = + find(fileRecordPath(referencesDirectory, fileId), codec::decodeReference); + result.ifPresent( + reference -> { + if (!reference.fileId().equals(fileId)) { + throw integrity("reference control record identity mismatch"); + } + }); + return result; + } + + private void storeOperationUpdate(Path target, DurablePublicationRecord candidate) { + verifyAttestedIdentity(); + try { + preparePrivateShard(target.getParent(), true); + Optional existing = readExisting(target, codec::decodeOperation); + if (existing.isPresent()) { + DurablePublicationRecord current = existing.orElseThrow(); + if (!current.operationId().equals(candidate.operationId())) { + throw integrity("stored operation identity does not match its lookup key"); + } + validateOperationTransition(current, candidate); + if (current.equals(candidate)) { + repairAndReadBack( + target, codec::decodeOperation, candidate, FaultSubject.operation(candidate)); + return; + } + } else { + validateInitialOperation(candidate); + } + durableWrite( + target, + codec.encodeOperation(candidate), + codec::decodeOperation, + candidate, + true, + null, + FaultSubject.operation(candidate)); + } catch (IOException exception) { + throw storage("operation control record cannot be durably stored", exception); + } + } + + private void storeImmutable( + Path target, + byte[] encoded, + Decoder decoder, + T candidate, + IdentityMatch identityMatch) { + verifyAttestedIdentity(); + try { + preparePrivateShard(target.getParent(), true); + Optional existing = readExisting(target, decoder); + if (existing.isPresent()) { + T current = existing.orElseThrow(); + if (!identityMatch.matches(current)) { + throw integrity("stored immutable identity does not match its lookup key"); + } + if (!current.equals(candidate)) { + throw conflict("immutable control record already exists with different content"); + } + repairAndReadBack(target, decoder, candidate, immutableFaultSubject(candidate)); + return; + } + durableWrite( + target, + encoded, + decoder, + candidate, + false, + identityMatch, + immutableFaultSubject(candidate)); + } catch (IOException exception) { + throw storage("immutable control record cannot be durably stored", exception); + } + } + + private void durableWrite( + Path target, + byte[] encoded, + Decoder decoder, + T expected, + boolean replaceExisting, + IdentityMatch identityMatch, + FaultSubject faultSubject) { + RecordLocation targetLocation = recordLocation(target); + String temporaryName = "." + target.getFileName() + "." + uniqueToken() + ".tmp"; + Path temporary = target.getParent().resolve(temporaryName); + CreatedTemporary created = null; + try { + created = + secureRecordOperations.createNewAndForce( + targetLocation.topDirectory(), targetLocation.shard(), temporaryName, encoded); + reached(faultSubject, FaultPoint.TEMP_FORCED); + requireCreatedTemporaryIdentity(targetLocation, temporaryName, created); + verifyAttestedIdentity(); + if (replaceExisting) { + Files.move( + temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } else { + try { + Files.createLink(target, temporary); + } catch (FileAlreadyExistsException collision) { + verifyAttestedIdentity(); + requireDeleteCreatedTemporary(targetLocation, temporaryName, created); + created = null; + T existing = + readExisting(target, decoder) + .orElseThrow(() -> integrity("immutable collision target disappeared")); + if (identityMatch != null && !identityMatch.matches(existing)) { + throw integrity("immutable collision identity does not match its lookup key"); + } + if (!expected.equals(existing)) { + throw conflict("immutable control record already exists with different content"); + } + repairAndReadBack(target, decoder, expected, faultSubject); + return; + } + } + verifyAttestedIdentity(); + if (replaceExisting) { + created = null; + } else { + requireDeleteCreatedTemporary(targetLocation, temporaryName, created); + created = null; + } + reached(faultSubject, FaultPoint.RECORD_REPLACED); + forceDirectory(target.getParent()); + reached(faultSubject, FaultPoint.PARENT_FORCED); + requireExactReadBack(target, decoder, expected); + verifyAttestedIdentity(); + } catch (IOException exception) { + cleanupCreatedTemporary(targetLocation, temporaryName, created, exception); + throw storage("control record cannot be durably stored", exception); + } catch (RuntimeException exception) { + cleanupCreatedTemporary(targetLocation, temporaryName, created, exception); + throw exception; + } + } + + private void repairAndReadBack( + Path target, Decoder decoder, T expected, FaultSubject faultSubject) throws IOException { + forceDirectory(target.getParent()); + reached(faultSubject, FaultPoint.PARENT_FORCED); + requireExactReadBack(target, decoder, expected); + verifyAttestedIdentity(); + } + + private void requireExactReadBack(Path target, Decoder decoder, T expected) + throws IOException { + T readBack = + readExisting(target, decoder) + .orElseThrow(() -> integrity("stored control record disappeared during read-back")); + if (!expected.equals(readBack)) { + throw integrity("stored control record failed canonical read-back"); + } + } + + private static void validateOperationTransition( + DurablePublicationRecord current, DurablePublicationRecord candidate) { + if (current.equals(candidate)) { + return; + } + if (!current.requestFingerprint().equals(candidate.requestFingerprint())) { + throw conflict("operation requestFingerprint cannot change"); + } + if (!sameOperationIdentity(current, candidate)) { + throw conflict("operation immutable identity cannot change"); + } + if (candidate.stateRevision() < current.stateRevision()) { + throw conflict("operation stateRevision cannot decrease"); + } + if (candidate.stateRevision() == current.stateRevision()) { + throw conflict("operation revision already exists with different content"); + } + if (current.stateRevision() == Long.MAX_VALUE + || candidate.stateRevision() != current.stateRevision() + 1) { + throw conflict("operation stateRevision must advance exactly once"); + } + if (!isAllowedAdjacentTransition(current.state(), candidate.state())) { + throw conflict("operation state must follow the adjacent transition matrix"); + } + if (current.state() != DurablePublicationRecord.State.WRITING) { + requireSealedFactsUnchanged(current, candidate); + } + if (current.state() == DurablePublicationRecord.State.MANIFEST_PUBLISHED + || current.state() == DurablePublicationRecord.State.REFERENCE_PUBLISHED) { + if (!current.manifestDigest().equals(candidate.manifestDigest())) { + throw conflict("operation manifestDigest cannot change after publication"); + } + } + if (current.state() == DurablePublicationRecord.State.REFERENCE_PUBLISHED + && !current.referenceDigest().equals(candidate.referenceDigest())) { + throw conflict("operation referenceDigest cannot change after publication"); + } + } + + private static void validateInitialOperation(DurablePublicationRecord candidate) { + if (candidate.state() != DurablePublicationRecord.State.WRITING + || candidate.stateRevision() != DurablePublicationRecord.State.WRITING.minimumRevision()) { + throw conflict("initial operation must be WRITING at its minimum revision"); + } + } + + private static boolean sameOperationIdentity( + DurablePublicationRecord current, DurablePublicationRecord candidate) { + return current.operationId().equals(candidate.operationId()) + && current.effectivePolicyRevision().equals(candidate.effectivePolicyRevision()) + && current.effectivePolicyDigest().equals(candidate.effectivePolicyDigest()) + && current.destinationId().equals(candidate.destinationId()) + && current.providerId().equals(candidate.providerId()) + && current.fileId().equals(candidate.fileId()) + && current.routeToken().equals(candidate.routeToken()) + && current.publishedFileName().equals(candidate.publishedFileName()) + && current.stageFileName().equals(candidate.stageFileName()) + && current.createdAt().equals(candidate.createdAt()); + } + + private static boolean isAllowedAdjacentTransition( + DurablePublicationRecord.State current, DurablePublicationRecord.State candidate) { + return switch (current) { + case WRITING -> + candidate == DurablePublicationRecord.State.SEALED + || candidate == DurablePublicationRecord.State.QUARANTINED; + case SEALED -> + candidate == DurablePublicationRecord.State.DATA_PUBLISHED + || candidate == DurablePublicationRecord.State.QUARANTINED; + case DATA_PUBLISHED -> + candidate == DurablePublicationRecord.State.MANIFEST_PUBLISHED + || candidate == DurablePublicationRecord.State.QUARANTINED; + case MANIFEST_PUBLISHED -> + candidate == DurablePublicationRecord.State.REFERENCE_PUBLISHED + || candidate == DurablePublicationRecord.State.QUARANTINED; + case REFERENCE_PUBLISHED -> + candidate == DurablePublicationRecord.State.PUBLISHED + || candidate == DurablePublicationRecord.State.QUARANTINED; + case PUBLISHED, QUARANTINED -> false; + }; + } + + private static void requireSealedFactsUnchanged( + DurablePublicationRecord current, DurablePublicationRecord candidate) { + if (current.byteSize() != candidate.byteSize() + || current.rowCount() != candidate.rowCount() + || current.columnCount() != candidate.columnCount() + || !current.sha256().equals(candidate.sha256()) + || current.formulaMitigatedCount() != candidate.formulaMitigatedCount() + || !Objects.equals(current.sealedAt(), candidate.sealedAt())) { + throw conflict("operation sealed facts cannot change after sealing"); + } + } + + private Optional find(Path target, Decoder decoder) { + verifyAttestedIdentity(); + try { + if (!preparePrivateShard(target.getParent(), false)) { + verifyAttestedIdentity(); + return Optional.empty(); + } + Optional result = readExisting(target, decoder); + verifyAttestedIdentity(); + return result; + } catch (IOException exception) { + throw storage("control record cannot be read", exception); + } + } + + private Optional readExisting(Path target, Decoder decoder) throws IOException { + RecordLocation location = recordLocation(target); + Optional secureRecord = + secureRecordOperations.read( + location.topDirectory(), + location.shard(), + location.relativeFileName(), + MAXIMUM_RECORD_BYTES); + if (secureRecord.isEmpty()) { + return Optional.empty(); + } + byte[] encoded = secureRecord.orElseThrow().bytes(); + try { + return Optional.of(decoder.decode(encoded)); + } catch (LocalPersistentControlPlaneException exception) { + throw exception; + } catch (RuntimeException exception) { + throw integrity("control record is not canonical or supported", exception); + } + } + + private StoredOperationRecord decodeStoredOperation(byte[] encoded) { + try { + return new R2StoredOperationRecord(codec.decodeOperation(encoded)); + } catch (RuntimeException versionTwoFailure) { + try { + LocalPublicationJournalRecord r1 = LocalPublicationJournalCodec.decodeCanonical(encoded); + if (r1.state() != LocalPublicationJournalRecord.State.PUBLISHED) { + throw new IllegalArgumentException( + "R1 compatibility operation must be terminal PUBLISHED"); + } + return new R1StoredOperationRecord(r1); + } catch (RuntimeException versionOneFailure) { + versionOneFailure.addSuppressed(versionTwoFailure); + throw new IllegalArgumentException( + "operation control record schema is corrupt or unsupported", versionOneFailure); + } + } + } + + private static FaultSubject immutableFaultSubject(Object candidate) { + if (candidate instanceof PrivateFileManifest manifest) { + return FaultSubject.immutable(ControlRecordKind.MANIFEST, manifest.fileId()); + } + if (candidate instanceof PublishedReferenceRecord reference) { + return FaultSubject.immutable(ControlRecordKind.REFERENCE, reference.fileId()); + } + throw new IllegalArgumentException("unsupported immutable control record type"); + } + + private void reached(FaultSubject subject, FaultPoint boundary) { + faultCallback.reached( + new FaultContext(subject.recordKind(), subject.identity(), subject.operation(), boundary)); + } + + private static ContextualFaultCallback contextual(FaultCallback callback) { + Objects.requireNonNull(callback, "faultCallback must be non-null"); + return context -> callback.reached(context.boundary()); + } + + private boolean preparePrivateShard(Path shard, boolean createIfMissing) throws IOException { + validateShardCoordinates(shard); + verifyAttestedIdentity(); + boolean created = false; + Path topDirectory = shard.getParent(); + String shardName = shard.getFileName().toString(); + Optional inspected = secureRecordOperations.statShard(topDirectory, shardName); + if (inspected.isEmpty()) { + if (!createIfMissing) { + verifyAttestedIdentity(); + return false; + } + try { + Files.createDirectory( + shard, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + created = true; + } catch (FileAlreadyExistsException ignored) { + // A concurrent creator must still pass the complete no-follow validation below. + } + } + inspected = secureRecordOperations.statShard(topDirectory, shardName); + validatePrivateShard(shard, inspected.orElseThrow(() -> integrity("control shard vanished"))); + if (created) { + forceDirectory(shard.getParent()); + } + verifyAttestedIdentity(); + return true; + } + + private void validatePrivateShard(Path shard, SecureShard attributes) throws IOException { + if (!attributes.directory() || attributes.symbolicLink()) { + throw integrity("control record shard is not a real directory"); + } + if (!attributes.owner().equals(evidence.expectedOwner())) { + throw integrity("control record shard owner does not match the attested owner"); + } + if (!attributes.permissions().equals(PosixFilePermissions.fromString("rwx------"))) { + throw integrity("control record shard permissions must be 0700"); + } + var store = Files.getFileStore(shard); + var topStore = Files.getFileStore(shard.getParent()); + if (!store.equals(topStore) + || !store.name().equals(evidence.fileStoreName()) + || !store.type().equals(evidence.fileStoreType())) { + throw integrity("control record shard FileStore does not match the attested root"); + } + } + + private void validateShardCoordinates(Path shard) { + Path top = shard.getParent(); + String name = shard.getFileName().toString(); + if ((!top.equals(operationsDirectory) + && !top.equals(manifestsDirectory) + && !top.equals(referencesDirectory)) + || !name.matches("[0-9a-f]{2}")) { + throw integrity("control record shard coordinates are unsafe"); + } + } + + private Path operationPath(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + String token = sha256(operationId); + return operationsDirectory.resolve(token.substring(0, 2)).resolve(token + ".json"); + } + + private static Path fileRecordPath(Path directory, String fileId) { + FileserverControlRecordCodec.requireFileId(fileId); + return directory.resolve(fileId.substring(0, 2)).resolve(fileId + ".json"); + } + + private static String sha256(String value) { + try { + return HEX.formatHex( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private static String uniqueToken() { + byte[] bytes = new byte[16]; + SECURE_RANDOM.nextBytes(bytes); + return HEX.formatHex(bytes); + } + + private String scopedOperationLockKey(String operationToken) { + return operationLockRootKey + "\u0000" + operationToken; + } + + private static String operationLockRootKey(LocalPersistentRootEvidence evidence) { + return evidence.root().toAbsolutePath().normalize() + + "\u0000" + + evidence.rootFileKey() + + "\u0000" + + evidence.fileStoreName() + + "\u0000" + + evidence.fileStoreType(); + } + + private void requireOperationLockRootHealthy() { + if (POISONED_OPERATION_LOCK_ROOTS.contains(operationLockRootKey)) { + throw storage("operation lock scope is poisoned because OS unlock was not proven"); + } + } + + private RecordLocation recordLocation(Path target) { + Path shardPath = target.getParent(); + validateShardCoordinates(shardPath); + String relativeFileName = target.getFileName().toString(); + Path relative = Path.of(relativeFileName); + if (relative.isAbsolute() + || relative.getNameCount() != 1 + || relativeFileName.equals(".") + || relativeFileName.equals("..")) { + throw integrity("control record filename is unsafe"); + } + return new RecordLocation( + shardPath.getParent(), shardPath.getFileName().toString(), relativeFileName); + } + + private void validateOperationLockFile(Path lockPath) throws IOException { + PosixFileAttributes attributes = + Files.readAttributes(lockPath, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw integrity("operation lock is not a regular file"); + } + if (!attributes.owner().getName().equals(evidence.expectedOwner())) { + throw integrity("operation lock owner does not match the attested owner"); + } + if (!attributes.permissions().equals(PosixFilePermissions.fromString("rw-------"))) { + throw integrity("operation lock permissions must be 0600"); + } + var store = Files.getFileStore(lockPath); + if (!store.name().equals(evidence.fileStoreName()) + || !store.type().equals(evidence.fileStoreType())) { + throw integrity("operation lock FileStore does not match the attested root"); + } + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private void verifyAttestedIdentity() { + try { + identityVerifier.verify(); + } catch (LocalPersistentControlPlaneException exception) { + throw exception; + } catch (RuntimeException exception) { + throw storage("attested persistent root identity verification failed", exception); + } + } + + private void requireDeleteCreatedTemporary( + RecordLocation target, String temporaryName, CreatedTemporary created) throws IOException { + requireCreatedTemporaryIdentity(target, temporaryName, created); + secureRecordOperations.deleteExact(target.topDirectory(), target.shard(), temporaryName); + } + + private void requireCreatedTemporaryIdentity( + RecordLocation target, String temporaryName, CreatedTemporary created) throws IOException { + Optional current = + secureRecordOperations.fileKeyNoFollow( + target.topDirectory(), target.shard(), temporaryName); + if (current.isEmpty() || !current.orElseThrow().equals(created.fileKey())) { + throw integrity("created temporary identity changed before commit or exact deletion"); + } + } + + private void cleanupCreatedTemporary( + RecordLocation target, String temporaryName, CreatedTemporary created, Throwable original) { + if (created == null) { + return; + } + try { + Optional current = + secureRecordOperations.fileKeyNoFollow( + target.topDirectory(), target.shard(), temporaryName); + if (current.isPresent() && current.orElseThrow().equals(created.fileKey())) { + secureRecordOperations.deleteExact(target.topDirectory(), target.shard(), temporaryName); + } + } catch (IOException cleanupFailure) { + original.addSuppressed(cleanupFailure); + } catch (RuntimeException cleanupFailure) { + original.addSuppressed(cleanupFailure); + } + } + + private static void withImmutableLock(String key, Runnable action) { + ReentrantLock lock = + IMMUTABLE_LOCK_STRIPES[Math.floorMod(key.hashCode(), OPERATION_LOCK_STRIPE_COUNT)]; + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } + + private static ReentrantLock[] createOperationLockStripes() { + ReentrantLock[] stripes = new ReentrantLock[OPERATION_LOCK_STRIPE_COUNT]; + for (int index = 0; index < stripes.length; index++) { + stripes[index] = new ReentrantLock(); + } + return stripes; + } + + private void closeLockAfterAcquireFailure( + FileLock fileLock, FileChannel channel, Throwable original) { + boolean releaseProvedUnlock = false; + boolean closeProvedUnlock = false; + if (fileLock != null) { + try { + lockLifecycle.release(fileLock); + releaseProvedUnlock = true; + } catch (IOException | RuntimeException closeFailure) { + original.addSuppressed(closeFailure); + } + } + if (channel != null) { + try { + lockLifecycle.close(channel); + closeProvedUnlock = true; + } catch (IOException | RuntimeException closeFailure) { + original.addSuppressed(closeFailure); + } + } + if (fileLock != null && !releaseProvedUnlock && !closeProvedUnlock) { + POISONED_OPERATION_LOCK_ROOTS.add(operationLockRootKey); + } + } + + private static LocalPersistentControlPlaneException conflict(String message) { + return new LocalPersistentControlPlaneException(FailureKind.CONFLICT, message); + } + + private static LocalPersistentControlPlaneException integrity(String message) { + return new LocalPersistentControlPlaneException(FailureKind.INTEGRITY, message); + } + + private static LocalPersistentControlPlaneException integrity(String message, Throwable cause) { + return new LocalPersistentControlPlaneException(FailureKind.INTEGRITY, message, cause); + } + + private static LocalPersistentControlPlaneException storage(String message) { + return new LocalPersistentControlPlaneException(FailureKind.STORAGE, message); + } + + private static LocalPersistentControlPlaneException storage(String message, Throwable cause) { + return new LocalPersistentControlPlaneException(FailureKind.STORAGE, message, cause); + } + + @FunctionalInterface + private interface Decoder { + T decode(byte[] bytes); + } + + @FunctionalInterface + private interface IdentityMatch { + boolean matches(T value); + } + + static SecureRecordOperations systemSecureRecordOperations() { + return SystemSecureRecordOperations.INSTANCE; + } + + static LockLifecycle systemLockLifecycle() { + return SystemLockLifecycle.INSTANCE; + } + + interface LockLifecycle { + + void release(FileLock lock) throws IOException; + + void close(FileChannel channel) throws IOException; + } + + interface SecureRecordOperations { + + Optional statShard(Path topDirectory, String shard) throws IOException; + + Optional read( + Path topDirectory, String shard, String relativeFileName, int maximumBytes) + throws IOException; + + CreatedTemporary createNewAndForce( + Path topDirectory, String shard, String relativeFileName, byte[] bytes) throws IOException; + + Optional fileKeyNoFollow(Path topDirectory, String shard, String relativeFileName) + throws IOException; + + void deleteExact(Path topDirectory, String shard, String relativeFileName) throws IOException; + } + + @FunctionalInterface + interface IdentityVerifier { + void verify(); + } + + enum SecureRecordAction { + STAT_SHARD_NOFOLLOW, + READ, + CREATE_NEW_AND_FORCE, + STAT_NOFOLLOW, + DELETE_EXACT + } + + record SecureRecordCall(Path topDirectory, String shard, String relativeFileName) {} + + record SecureShard( + boolean directory, + boolean symbolicLink, + String owner, + Set permissions, + String fileKey) { + + SecureShard { + Objects.requireNonNull(owner, "owner must be non-null"); + permissions = Set.copyOf(permissions); + Objects.requireNonNull(fileKey, "fileKey must be non-null"); + } + } + + static final class SecureRecord { + + private final byte[] bytes; + private final String fileKey; + + private SecureRecord(byte[] bytes, String fileKey) { + this.bytes = bytes.clone(); + this.fileKey = Objects.requireNonNull(fileKey, "fileKey must be non-null"); + } + + byte[] bytes() { + return bytes.clone(); + } + + String fileKey() { + return fileKey; + } + } + + record CreatedTemporary(String fileKey) { + + CreatedTemporary { + Objects.requireNonNull(fileKey, "fileKey must be non-null"); + } + } + + private record RecordLocation(Path topDirectory, String shard, String relativeFileName) {} + + private enum SystemLockLifecycle implements LockLifecycle { + INSTANCE; + + @Override + public void release(FileLock lock) throws IOException { + lock.release(); + } + + @Override + public void close(FileChannel channel) throws IOException { + channel.close(); + } + } + + private enum SystemSecureRecordOperations implements SecureRecordOperations { + INSTANCE; + + @Override + public Optional statShard(Path topDirectory, String shard) throws IOException { + validateShardCoordinates(topDirectory, shard); + try (SecureDirectoryStream top = openSecure(topDirectory)) { + PosixFileAttributeView view = + top.getFileAttributeView( + Path.of(shard), PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + if (view == null) { + throw new IOException("secure shard POSIX attribute view is unavailable"); + } + try { + PosixFileAttributes attributes = view.readAttributes(); + if (attributes.fileKey() == null) { + throw integrity("control record shard has no stable file identity"); + } + return Optional.of( + new SecureShard( + attributes.isDirectory(), + attributes.isSymbolicLink(), + attributes.owner().getName(), + attributes.permissions(), + attributes.fileKey().toString())); + } catch (NoSuchFileException missing) { + return Optional.empty(); + } + } + } + + @Override + public Optional read( + Path topDirectory, String shard, String relativeFileName, int maximumBytes) + throws IOException { + validateCoordinates(topDirectory, shard, relativeFileName); + try (SecureDirectoryStream top = openSecure(topDirectory); + SecureDirectoryStream directory = + top.newDirectoryStream(Path.of(shard), LinkOption.NOFOLLOW_LINKS)) { + Path relative = Path.of(relativeFileName); + BasicFileAttributes before; + try { + before = attributes(directory, relative); + } catch (NoSuchFileException missing) { + return Optional.empty(); + } + validateRegularRecord(before, maximumBytes); + byte[] bytes = readBounded(directory, relative, before, maximumBytes); + BasicFileAttributes after = attributes(directory, relative); + validateRegularRecord(after, maximumBytes); + String beforeKey = requireFileKey(before); + if (!beforeKey.equals(requireFileKey(after)) + || before.size() != after.size() + || bytes.length != after.size()) { + throw integrity("control record identity changed while being read"); + } + return Optional.of(new SecureRecord(bytes, beforeKey)); + } + } + + @Override + public CreatedTemporary createNewAndForce( + Path topDirectory, String shard, String relativeFileName, byte[] bytes) throws IOException { + validateCoordinates(topDirectory, shard, relativeFileName); + try (SecureDirectoryStream top = openSecure(topDirectory); + SecureDirectoryStream directory = + top.newDirectoryStream(Path.of(shard), LinkOption.NOFOLLOW_LINKS)) { + Path relative = Path.of(relativeFileName); + String createdKey = null; + try { + try (SeekableByteChannel channel = + directory.newByteChannel( + relative, + TEMPORARY_WRITE_OPTIONS, + PosixFilePermissions.asFileAttribute( + PosixFilePermissions.fromString("rw-------")))) { + BasicFileAttributes createdAttributes = attributes(directory, relative); + validateRegularRecord(createdAttributes, MAXIMUM_RECORD_BYTES); + createdKey = requireFileKey(createdAttributes); + if (!(channel instanceof FileChannel fileChannel)) { + throw new IOException("secure relative temporary channel cannot be forced"); + } + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + fileChannel.write(buffer); + } + fileChannel.force(true); + } + BasicFileAttributes attributes = attributes(directory, relative); + validateRegularRecord(attributes, MAXIMUM_RECORD_BYTES); + if (attributes.size() != bytes.length || !createdKey.equals(requireFileKey(attributes))) { + throw integrity("forced temporary identity or size does not match its creation"); + } + return new CreatedTemporary(createdKey); + } catch (IOException | RuntimeException failure) { + cleanupCreatedRelative(directory, relative, createdKey, failure); + throw failure; + } + } + } + + @Override + public Optional fileKeyNoFollow( + Path topDirectory, String shard, String relativeFileName) throws IOException { + validateCoordinates(topDirectory, shard, relativeFileName); + try (SecureDirectoryStream top = openSecure(topDirectory); + SecureDirectoryStream directory = + top.newDirectoryStream(Path.of(shard), LinkOption.NOFOLLOW_LINKS)) { + try { + BasicFileAttributes attributes = attributes(directory, Path.of(relativeFileName)); + validateRegularRecord(attributes, MAXIMUM_RECORD_BYTES); + return Optional.of(requireFileKey(attributes)); + } catch (NoSuchFileException missing) { + return Optional.empty(); + } + } + } + + @Override + public void deleteExact(Path topDirectory, String shard, String relativeFileName) + throws IOException { + validateCoordinates(topDirectory, shard, relativeFileName); + try (SecureDirectoryStream top = openSecure(topDirectory); + SecureDirectoryStream directory = + top.newDirectoryStream(Path.of(shard), LinkOption.NOFOLLOW_LINKS)) { + directory.deleteFile(Path.of(relativeFileName)); + } + } + + private static byte[] readBounded( + SecureDirectoryStream directory, + Path relative, + BasicFileAttributes attributes, + int maximumBytes) + throws IOException { + if (attributes.size() > maximumBytes) { + throw integrity("control record exceeds the maximum size"); + } + ByteBuffer buffer = ByteBuffer.allocate(maximumBytes + 1); + try (SeekableByteChannel channel = + directory.newByteChannel( + relative, Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { + // Continue until EOF or one byte beyond the accepted bound. + } + } + if (buffer.position() > maximumBytes) { + throw integrity("control record exceeds the maximum size"); + } + return java.util.Arrays.copyOf(buffer.array(), buffer.position()); + } + + private static BasicFileAttributes attributes( + SecureDirectoryStream directory, Path relative) throws IOException { + BasicFileAttributeView view = + directory.getFileAttributeView( + relative, BasicFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + if (view == null) { + throw new IOException("secure no-follow attribute view is unavailable"); + } + return view.readAttributes(); + } + + private static void cleanupCreatedRelative( + SecureDirectoryStream directory, + Path relative, + String createdKey, + Throwable original) { + if (createdKey == null) { + return; + } + try { + BasicFileAttributes current = attributes(directory, relative); + if (current.isRegularFile() + && !current.isSymbolicLink() + && createdKey.equals(requireFileKey(current))) { + directory.deleteFile(relative); + } + } catch (NoSuchFileException ignored) { + // The exact created entry is already absent. + } catch (IOException | RuntimeException cleanupFailure) { + original.addSuppressed(cleanupFailure); + } + } + + private static void validateRegularRecord(BasicFileAttributes attributes, int maximumBytes) { + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw integrity("control record is not a no-follow regular file"); + } + if (attributes.size() > maximumBytes) { + throw integrity("control record exceeds the maximum size"); + } + requireFileKey(attributes); + } + + private static String requireFileKey(BasicFileAttributes attributes) { + if (attributes.fileKey() == null) { + throw integrity("control record has no stable file identity"); + } + return attributes.fileKey().toString(); + } + + private static void validateCoordinates( + Path topDirectory, String shard, String relativeFileName) { + Path relative = Path.of(relativeFileName); + if (!topDirectory.isAbsolute() + || !topDirectory.normalize().equals(topDirectory) + || !shard.matches("[0-9a-f]{2}") + || relative.isAbsolute() + || relative.getNameCount() != 1 + || relativeFileName.equals(".") + || relativeFileName.equals("..")) { + throw integrity("secure record coordinates are unsafe"); + } + } + + private static void validateShardCoordinates(Path topDirectory, String shard) { + if (!topDirectory.isAbsolute() + || !topDirectory.normalize().equals(topDirectory) + || !shard.matches("[0-9a-f]{2}")) { + throw integrity("secure shard coordinates are unsafe"); + } + } + + @SuppressWarnings({"StreamResourceLeak", "unchecked"}) + private static SecureDirectoryStream openSecure(Path directory) throws IOException { + DirectoryStream stream = Files.newDirectoryStream(directory); + if (stream instanceof SecureDirectoryStream secure) { + return (SecureDirectoryStream) secure; + } + stream.close(); + throw new IOException("SecureDirectoryStream unavailable for " + directory); + } + } + + enum FaultPoint { + TEMP_FORCED, + RECORD_REPLACED, + PARENT_FORCED + } + + enum ControlRecordKind { + OPERATION, + MANIFEST, + REFERENCE + } + + record OperationFaultState(DurablePublicationRecord.State state, long stateRevision) { + + OperationFaultState { + Objects.requireNonNull(state, "state must be non-null"); + if (stateRevision < 1) { + throw new IllegalArgumentException("stateRevision must be positive"); + } + } + } + + record FaultContext( + ControlRecordKind recordKind, + String identity, + Optional operation, + FaultPoint boundary) { + + FaultContext { + Objects.requireNonNull(recordKind, "recordKind must be non-null"); + FileserverControlRecordCodec.requireText(identity, "identity", 128); + Objects.requireNonNull(operation, "operation must be non-null"); + Objects.requireNonNull(boundary, "boundary must be non-null"); + if ((recordKind == ControlRecordKind.OPERATION) != operation.isPresent()) { + throw new IllegalArgumentException( + "operation fault state presence must match the control record kind"); + } + } + } + + sealed interface StoredOperationRecord permits R1StoredOperationRecord, R2StoredOperationRecord { + + String operationId(); + } + + record R1StoredOperationRecord(LocalPublicationJournalRecord record) + implements StoredOperationRecord { + + R1StoredOperationRecord { + Objects.requireNonNull(record, "record must be non-null"); + if (record.state() != LocalPublicationJournalRecord.State.PUBLISHED) { + throw new IllegalArgumentException("R1 compatibility record must be terminal PUBLISHED"); + } + } + + @Override + public String operationId() { + return record.operationId(); + } + } + + record R2StoredOperationRecord(DurablePublicationRecord record) implements StoredOperationRecord { + + R2StoredOperationRecord { + Objects.requireNonNull(record, "record must be non-null"); + } + + @Override + public String operationId() { + return record.operationId(); + } + } + + private record FaultSubject( + ControlRecordKind recordKind, String identity, Optional operation) { + + private static FaultSubject operation(DurablePublicationRecord record) { + return new FaultSubject( + ControlRecordKind.OPERATION, + record.operationId(), + Optional.of(new OperationFaultState(record.state(), record.stateRevision()))); + } + + private static FaultSubject immutable(ControlRecordKind kind, String identity) { + return new FaultSubject(kind, identity, Optional.empty()); + } + } + + enum FailureKind { + CONFLICT, + INTEGRITY, + STORAGE + } + + @FunctionalInterface + interface FaultCallback { + void reached(FaultPoint point); + } + + @FunctionalInterface + interface ContextualFaultCallback { + void reached(FaultContext context); + } + + static final class OperationLock implements AutoCloseable { + + private final String rootKey; + private final String scopedLockKey; + private final Thread owner; + private final ReentrantLock jvmLock; + private final FileChannel channel; + private final FileLock fileLock; + private final LockLifecycle lifecycle; + private final AtomicBoolean closed = new AtomicBoolean(); + + private OperationLock( + String rootKey, + String scopedLockKey, + Thread owner, + ReentrantLock jvmLock, + FileChannel channel, + FileLock fileLock, + LockLifecycle lifecycle) { + this.rootKey = rootKey; + this.scopedLockKey = scopedLockKey; + this.owner = owner; + this.jvmLock = jvmLock; + this.channel = channel; + this.fileLock = fileLock; + this.lifecycle = lifecycle; + } + + @Override + public void close() { + if (Thread.currentThread() != owner) { + throw new IllegalStateException("operation lock must be closed by its owning thread"); + } + if (!closed.compareAndSet(false, true)) { + return; + } + LocalPersistentControlPlaneException failure = null; + boolean releaseProvedUnlock = false; + boolean closeProvedUnlock = false; + try { + lifecycle.release(fileLock); + releaseProvedUnlock = true; + } catch (IOException | RuntimeException exception) { + failure = storage("operation OS lock cannot be released", exception); + } + try { + lifecycle.close(channel); + closeProvedUnlock = true; + } catch (IOException | RuntimeException exception) { + if (failure == null) { + failure = storage("operation lock channel cannot be closed", exception); + } else { + failure.addSuppressed(exception); + } + } finally { + if (!releaseProvedUnlock && !closeProvedUnlock) { + POISONED_OPERATION_LOCK_ROOTS.add(rootKey); + } + Set heldTokens = HELD_OPERATION_TOKENS.get(); + if (heldTokens != null) { + heldTokens.remove(scopedLockKey); + } + if (heldTokens == null || heldTokens.isEmpty()) { + HELD_OPERATION_TOKENS.remove(); + } + jvmLock.unlock(); + } + if (failure != null) { + throw failure; + } + } + } + + static final class LocalPersistentControlPlaneException extends RuntimeException { + + private final FailureKind kind; + + private LocalPersistentControlPlaneException(FailureKind kind, String message) { + super(message); + this.kind = kind; + } + + private LocalPersistentControlPlaneException( + FailureKind kind, String message, Throwable cause) { + super(message, cause); + this.kind = kind; + } + + FailureKind kind() { + return kind; + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java new file mode 100644 index 0000000..8ac6192 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java @@ -0,0 +1,1098 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Secure payload boundary for the qualified local-persistent Fileserver R2 provider. + * + *

Caller values are converted to bounded generated segments before filesystem access. Reads, + * writes, and exact deletes use {@link SecureDirectoryStream}; the JDK's missing relative + * hard-link, directory-create, and directory-force primitives are bracketed by attested identity + * checks in this class. + */ +final class LocalPersistentPayloadOperations { + + private static final Set PRIVATE_DIRECTORY_PERMISSIONS = + PosixFilePermissions.fromString("rwx------"); + private static final Set PRIVATE_FILE_PERMISSIONS = + PosixFilePermissions.fromString("rw-------"); + private static final Set CREATE_STAGE_OPTIONS = + Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + private static final Set READ_OPTIONS = + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + private static final HexFormat HEX = HexFormat.of(); + + private final LocalPersistentRootEvidence evidence; + private final PayloadAccess access; + private final IdentityVerifier identityVerifier; + private final FaultCallback faultCallback; + private final Path stagingDirectory; + private final Path dataDirectory; + + LocalPersistentPayloadOperations( + LocalPersistentRootAttestor attestor, LocalPersistentRootEvidence evidence) { + this(attestor, evidence, systemPayloadAccess(), null, ignored -> {}); + } + + LocalPersistentPayloadOperations( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + FaultCallback faultCallback) { + this(attestor, evidence, systemPayloadAccess(), null, faultCallback); + } + + LocalPersistentPayloadOperations( + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence, + PayloadAccess access, + IdentityVerifier identityVerifier, + FaultCallback faultCallback) { + LocalPersistentRootAttestor requiredAttestor = + Objects.requireNonNull(attestor, "attestor must be non-null"); + this.evidence = Objects.requireNonNull(evidence, "evidence must be non-null"); + this.access = Objects.requireNonNull(access, "access must be non-null"); + this.identityVerifier = + identityVerifier == null + ? () -> requiredAttestor.verifyIdentity(this.evidence) + : identityVerifier; + this.faultCallback = Objects.requireNonNull(faultCallback, "faultCallback must be non-null"); + stagingDirectory = evidence.root().resolve(".ca-fileserver").resolve("staging"); + dataDirectory = evidence.root().resolve("data"); + requireTopDirectory(stagingDirectory, ".ca-fileserver/staging"); + requireTopDirectory(dataDirectory, "data"); + } + + static String stageFileName(String operationId) { + requireOperationId(operationId); + return "op-" + sha256(operationId) + ".part"; + } + + Path root() { + return evidence.root(); + } + + VerifiedArtifact stage( + String operationId, String stageFileName, long maximumBytes, PayloadWriter writer) { + requireOperationId(operationId); + requireExactStageFileName(operationId, stageFileName); + requirePositiveMaximum(maximumBytes); + Objects.requireNonNull(writer, "writer must be non-null"); + String shard = operationShard(operationId); + verifyIdentity(); + requireTopDirectory(stagingDirectory, ".ca-fileserver/staging"); + preparePrivateShard(stagingDirectory, shard); + VerifiedArtifact staged; + try { + staged = + access.createStageAndForce( + stagingDirectory, shard, stageFileName, operationId, maximumBytes, writer); + } catch (StageWriteFailureException failure) { + Throwable original = failure.original(); + cleanupFailedStage(shard, stageFileName, failure.createdFileKey(), original); + throwOriginal(original); + throw new AssertionError("unreachable"); + } catch (FileAlreadyExistsException exception) { + throw failure(FailureKind.CONFLICT, "payload stage already exists", exception); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.STORAGE, "payload stage cannot be durably written", exception); + } + try { + requireArtifact( + staged, ArtifactKind.STAGE, operationId, shard, stageFileName, maximumBytes, null); + } catch (LocalPersistentPayloadException exception) { + cleanupFailedStage(shard, stageFileName, staged.fileKey(), exception); + throw exception; + } + verifyIdentity(); + reached(new FaultContext(FaultPoint.STAGE_FORCED, operationId, null, staged)); + return staged; + } + + Optional inspectStage( + String operationId, String stageFileName, long maximumBytes) { + requireOperationId(operationId); + requireExactStageFileName(operationId, stageFileName); + requirePositiveMaximum(maximumBytes); + String shard = operationShard(operationId); + verifyIdentity(); + requireTopDirectory(stagingDirectory, ".ca-fileserver/staging"); + return inspectExisting( + ArtifactKind.STAGE, stagingDirectory, shard, stageFileName, operationId, maximumBytes); + } + + Optional inspectData( + String fileId, String publishedFileName, long maximumBytes) { + requireFileId(fileId); + requireGeneratedSegment(publishedFileName, "publishedFileName"); + requirePositiveMaximum(maximumBytes); + verifyIdentity(); + requireTopDirectory(dataDirectory, "data"); + return inspectExisting( + ArtifactKind.DATA, + dataDirectory, + dataShard(fileId), + publishedFileName, + fileId, + maximumBytes); + } + + Optional inspectLegacyRootArtifact( + String publishedFileName, long maximumBytes) { + requireLegacySegment(publishedFileName); + requirePositiveMaximum(maximumBytes); + verifyIdentity(); + try { + VerifiedArtifact artifact = + access.inspect( + ArtifactKind.LEGACY_ROOT, + evidence.root(), + null, + publishedFileName, + publishedFileName, + maximumBytes); + if (artifact == null) { + return Optional.empty(); + } + requireArtifact( + artifact, + ArtifactKind.LEGACY_ROOT, + publishedFileName, + null, + publishedFileName, + maximumBytes, + null); + verifyIdentity(); + return Optional.of(artifact); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.STORAGE, "legacy payload cannot be inspected", exception); + } + } + + VerifiedArtifact publishData( + VerifiedArtifact staged, String fileId, String publishedFileName, long maximumBytes) { + Objects.requireNonNull(staged, "staged artifact must be non-null"); + requireFileId(fileId); + requireGeneratedSegment(publishedFileName, "publishedFileName"); + requirePositiveMaximum(maximumBytes); + if (staged.kind() != ArtifactKind.STAGE) { + throw new IllegalArgumentException("staged artifact must have STAGE kind"); + } + verifyIdentity(); + requireTopDirectory(stagingDirectory, ".ca-fileserver/staging"); + requireTopDirectory(dataDirectory, "data"); + String dataShard = dataShard(fileId); + preparePrivateShard(dataDirectory, dataShard); + VerifiedArtifact currentStage = + inspectExisting( + ArtifactKind.STAGE, + stagingDirectory, + staged.shard(), + staged.fileName(), + staged.identity(), + maximumBytes) + .orElseThrow( + () -> failure(FailureKind.INTEGRITY, "sealed payload stage is missing", null)); + requireSameArtifact(staged, currentStage, "sealed payload stage identity changed"); + Path target = dataDirectory.resolve(dataShard).resolve(publishedFileName); + Path existing = stagingDirectory.resolve(staged.shard()).resolve(staged.fileName()); + boolean linked = false; + try { + verifyIdentity(); + requireShard(dataDirectory, dataShard); + requireShard(stagingDirectory, staged.shard()); + VerifiedArtifact immediatelyBeforeLink = + inspectExisting( + ArtifactKind.STAGE, + stagingDirectory, + staged.shard(), + staged.fileName(), + staged.identity(), + maximumBytes) + .orElseThrow( + () -> failure(FailureKind.INTEGRITY, "sealed payload stage is missing", null)); + requireSameArtifact(staged, immediatelyBeforeLink, "sealed payload stage identity changed"); + access.createHardLink(target, existing); + linked = true; + reached(new FaultContext(FaultPoint.DATA_LINKED, fileId, dataShard, null)); + verifyIdentity(); + requireShard(dataDirectory, dataShard); + VerifiedArtifact data = + inspectExisting( + ArtifactKind.DATA, + dataDirectory, + dataShard, + publishedFileName, + fileId, + maximumBytes) + .orElseThrow( + () -> + failure( + FailureKind.INDETERMINATE, + "linked payload data cannot be read back", + null)); + requireSameContentAndFileKey(staged, data, "published data differs from sealed stage"); + access.forceDirectory(dataDirectory.resolve(dataShard)); + verifyIdentity(); + requireShard(dataDirectory, dataShard); + reached(new FaultContext(FaultPoint.DATA_DIRECTORY_FORCED, fileId, dataShard, data)); + return data; + } catch (FileAlreadyExistsException exception) { + throw failure(FailureKind.CONFLICT, "payload target already exists", exception); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure( + linked ? FailureKind.INDETERMINATE : FailureKind.STORAGE, + "payload data publication failed", + exception); + } + } + + void forceDataDirectory(String fileId) { + requireFileId(fileId); + String shard = dataShard(fileId); + verifyIdentity(); + requireTopDirectory(dataDirectory, "data"); + requireShard(dataDirectory, shard); + try { + access.forceDirectory(dataDirectory.resolve(shard)); + verifyIdentity(); + requireShard(dataDirectory, shard); + reached(new FaultContext(FaultPoint.DATA_DIRECTORY_FORCED, fileId, shard, null)); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.INDETERMINATE, "payload data directory force failed", exception); + } + } + + void deleteStageExact(VerifiedArtifact staged) { + Objects.requireNonNull(staged, "staged artifact must be non-null"); + if (staged.kind() != ArtifactKind.STAGE) { + throw new IllegalArgumentException("staged artifact must have STAGE kind"); + } + verifyIdentity(); + requireTopDirectory(stagingDirectory, ".ca-fileserver/staging"); + requireShard(stagingDirectory, staged.shard()); + try { + boolean deleted = + access.deleteExact(stagingDirectory, staged.shard(), staged.fileName(), staged); + if (!deleted) { + return; + } + access.forceDirectory(stagingDirectory.resolve(staged.shard())); + verifyIdentity(); + requireShard(stagingDirectory, staged.shard()); + reached( + new FaultContext(FaultPoint.STAGE_DELETED, staged.identity(), staged.shard(), staged)); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.INDETERMINATE, "payload stage exact delete failed", exception); + } + } + + private Optional inspectExisting( + ArtifactKind kind, + Path topDirectory, + String shard, + String fileName, + String identity, + long maximumBytes) { + ShardAttributes shardAttributes = statShard(topDirectory, shard); + if (shardAttributes == null) { + return Optional.empty(); + } + requireShardAttributes(shardAttributes); + try { + VerifiedArtifact artifact = + access.inspect(kind, topDirectory, shard, fileName, identity, maximumBytes); + if (artifact == null) { + return Optional.empty(); + } + requireArtifact(artifact, kind, identity, shard, fileName, maximumBytes, shardAttributes); + verifyIdentity(); + return Optional.of(artifact); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.STORAGE, "payload artifact cannot be inspected", exception); + } + } + + private void preparePrivateShard(Path topDirectory, String shard) { + ShardAttributes attributes = statShard(topDirectory, shard); + if (attributes == null) { + try { + attributes = access.createPrivateShard(topDirectory, shard); + access.forceDirectory(topDirectory); + } catch (FileAlreadyExistsException ignored) { + attributes = statShard(topDirectory, shard); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.STORAGE, "private payload shard cannot be prepared", exception); + } + } + if (attributes == null) { + throw failure(FailureKind.INTEGRITY, "private payload shard disappeared", null); + } + requireShardAttributes(attributes); + requireTopDirectory( + topDirectory, topDirectory.equals(dataDirectory) ? "data" : ".ca-fileserver/staging"); + } + + private ShardAttributes statShard(Path topDirectory, String shard) { + try { + return access.statShard(topDirectory, shard); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure(FailureKind.STORAGE, "private payload shard cannot be inspected", exception); + } + } + + private void requireShard(Path topDirectory, String shard) { + ShardAttributes attributes = statShard(topDirectory, shard); + if (attributes == null) { + throw failure(FailureKind.INTEGRITY, "private payload shard is missing", null); + } + requireShardAttributes(attributes); + } + + private void requireShardAttributes(ShardAttributes attributes) { + if (!evidence.expectedOwner().equals(attributes.owner()) + || !PRIVATE_DIRECTORY_PERMISSIONS.equals(attributes.permissions()) + || !evidence.fileStoreName().equals(attributes.fileStoreName()) + || !evidence.fileStoreType().equals(attributes.fileStoreType()) + || attributes.fileKey() == null + || attributes.fileKey().isBlank()) { + throw failure(FailureKind.INTEGRITY, "private payload shard attestation failed", null); + } + } + + private void requireTopDirectory(Path directory, String evidenceKey) { + String expectedFileKey = evidence.criticalDirectoryFileKeys().get(evidenceKey); + if (expectedFileKey == null) { + throw failure(FailureKind.INTEGRITY, "payload top directory is not attested", null); + } + try { + PosixFileAttributes attributes = + Files.readAttributes(directory, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isDirectory() + || attributes.isSymbolicLink() + || !expectedFileKey.equals(fileKey(attributes.fileKey())) + || !evidence.expectedOwner().equals(attributes.owner().getName()) + || !PRIVATE_DIRECTORY_PERMISSIONS.equals(attributes.permissions()) + || !evidence.fileStoreName().equals(access.fileStoreName(directory)) + || !evidence.fileStoreType().equals(access.fileStoreType(directory))) { + throw failure(FailureKind.INTEGRITY, "payload top directory attestation failed", null); + } + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (IOException exception) { + throw failure( + FailureKind.INDETERMINATE, "payload top directory cannot be attested", exception); + } + } + + private void requireArtifact( + VerifiedArtifact artifact, + ArtifactKind kind, + String identity, + String shard, + String fileName, + long maximumBytes, + ShardAttributes shardAttributes) { + if (artifact.kind() != kind + || !identity.equals(artifact.identity()) + || !Objects.equals(shard, artifact.shard()) + || !fileName.equals(artifact.fileName()) + || artifact.byteSize() < 0 + || artifact.byteSize() > maximumBytes + || !artifact.sha256().matches("[0-9a-f]{64}") + || artifact.fileKey().isBlank() + || !evidence.expectedOwner().equals(artifact.owner()) + || !PRIVATE_FILE_PERMISSIONS.equals(artifact.permissions()) + || !evidence.fileStoreName().equals(artifact.fileStoreName()) + || !evidence.fileStoreType().equals(artifact.fileStoreType())) { + throw failure(FailureKind.INTEGRITY, "payload artifact attestation failed", null); + } + if (shardAttributes != null + && (!shardAttributes.fileStoreName().equals(artifact.fileStoreName()) + || !shardAttributes.fileStoreType().equals(artifact.fileStoreType()))) { + throw failure(FailureKind.INTEGRITY, "payload artifact FileStore mismatch", null); + } + } + + private static void requireSameArtifact( + VerifiedArtifact expected, VerifiedArtifact actual, String message) { + if (!expected.equals(actual)) { + throw failure(FailureKind.INTEGRITY, message, null); + } + } + + private static void requireSameContentAndFileKey( + VerifiedArtifact expected, VerifiedArtifact actual, String message) { + if (!expected.fileKey().equals(actual.fileKey()) + || expected.byteSize() != actual.byteSize() + || !expected.sha256().equals(actual.sha256())) { + throw failure(FailureKind.INTEGRITY, message, null); + } + } + + private void cleanupFailedStage( + String shard, String stageFileName, String createdFileKey, Throwable original) { + if (createdFileKey == null) { + return; + } + try { + access.cleanupCreatedStage(stagingDirectory, shard, stageFileName, createdFileKey, original); + } catch (Throwable cleanupFailure) { + if (cleanupFailure != original) { + original.addSuppressed(cleanupFailure); + } + } + } + + private void verifyIdentity() { + try { + identityVerifier.verify(); + } catch (LocalPersistentPayloadException exception) { + throw exception; + } catch (RuntimeException exception) { + throw failure( + FailureKind.INDETERMINATE, "persistent payload root identity changed", exception); + } + } + + private void reached(FaultContext context) { + faultCallback.reached(context); + } + + static PayloadAccess systemPayloadAccess() { + return SystemPayloadAccess.INSTANCE; + } + + private static String operationShard(String operationId) { + return sha256(operationId).substring(0, 2); + } + + private static String dataShard(String fileId) { + return fileId.substring(0, 2); + } + + private static void requireOperationId(String operationId) { + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + } + + private static void requireFileId(String fileId) { + if (fileId == null || !fileId.matches("[0-9a-f]{32}")) { + throw new IllegalArgumentException("fileId must be 32 lowercase hexadecimal characters"); + } + } + + private static void requireExactStageFileName(String operationId, String stageFileName) { + if (!stageFileName(operationId).equals(stageFileName)) { + throw new IllegalArgumentException("stageFileName must be derived from operationId"); + } + } + + private static void requireGeneratedSegment(String value, String field) { + FileserverControlRecordCodec.requireSegment(value, field); + } + + private static void requireLegacySegment(String value) { + if (value == null + || value.isBlank() + || ".".equals(value) + || "..".equals(value) + || value.indexOf('/') >= 0 + || value.indexOf('\\') >= 0 + || value.codePoints().anyMatch(codePoint -> Character.isISOControl(codePoint))) { + throw new IllegalArgumentException("legacy publishedFileName must be one safe segment"); + } + try { + int bytes = + StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)) + .remaining(); + if (bytes > 255) { + throw new IllegalArgumentException( + "legacy publishedFileName UTF-8 encoding exceeds 255 bytes"); + } + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException("legacy publishedFileName must be valid UTF-8", exception); + } + } + + private static void requirePositiveMaximum(long maximumBytes) { + if (maximumBytes < 1) { + throw new IllegalArgumentException("maximumBytes must be positive"); + } + } + + private static String sha256(String value) { + return HEX.formatHex(sha256().digest(value.getBytes(StandardCharsets.UTF_8))); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static String fileKey(Object value) { + if (value == null || value.toString().isBlank()) { + throw failure(FailureKind.INTEGRITY, "payload filesystem has no stable file key", null); + } + return value.toString(); + } + + private static LocalPersistentPayloadException failure( + FailureKind kind, String message, Throwable cause) { + return cause == null + ? new LocalPersistentPayloadException(kind, message) + : new LocalPersistentPayloadException(kind, message, cause); + } + + private static void throwOriginal(Throwable original) { + if (original instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (original instanceof Error error) { + throw error; + } + throw failure(FailureKind.STORAGE, "payload writer failed", original); + } + + enum ArtifactKind { + STAGE, + DATA, + LEGACY_ROOT + } + + enum FailureKind { + CAPACITY, + CONFLICT, + INTEGRITY, + STORAGE, + INDETERMINATE + } + + enum FaultPoint { + STAGE_FORCED, + DATA_LINKED, + DATA_DIRECTORY_FORCED, + STAGE_DELETED + } + + record VerifiedArtifact( + ArtifactKind kind, + String identity, + String shard, + String fileName, + long byteSize, + String sha256, + String fileKey, + String owner, + Set permissions, + String fileStoreName, + String fileStoreType) { + + VerifiedArtifact { + Objects.requireNonNull(kind, "kind must be non-null"); + Objects.requireNonNull(identity, "identity must be non-null"); + Objects.requireNonNull(fileName, "fileName must be non-null"); + Objects.requireNonNull(sha256, "sha256 must be non-null"); + Objects.requireNonNull(fileKey, "fileKey must be non-null"); + Objects.requireNonNull(owner, "owner must be non-null"); + permissions = Set.copyOf(Objects.requireNonNull(permissions, "permissions must be non-null")); + Objects.requireNonNull(fileStoreName, "fileStoreName must be non-null"); + Objects.requireNonNull(fileStoreType, "fileStoreType must be non-null"); + } + } + + record ShardAttributes( + String fileKey, + String owner, + Set permissions, + String fileStoreName, + String fileStoreType) { + + ShardAttributes { + Objects.requireNonNull(fileKey, "fileKey must be non-null"); + Objects.requireNonNull(owner, "owner must be non-null"); + permissions = Set.copyOf(Objects.requireNonNull(permissions, "permissions must be non-null")); + Objects.requireNonNull(fileStoreName, "fileStoreName must be non-null"); + Objects.requireNonNull(fileStoreType, "fileStoreType must be non-null"); + } + } + + record FaultContext(FaultPoint point, String identity, String shard, VerifiedArtifact artifact) { + + FaultContext { + Objects.requireNonNull(point, "point must be non-null"); + Objects.requireNonNull(identity, "identity must be non-null"); + } + } + + @FunctionalInterface + interface PayloadWriter { + void write(OutputStream output) throws IOException; + } + + @FunctionalInterface + interface IdentityVerifier { + void verify(); + } + + @FunctionalInterface + interface FaultCallback { + void reached(FaultContext context); + } + + interface PayloadAccess { + + ShardAttributes statShard(Path topDirectory, String shard) throws IOException; + + ShardAttributes createPrivateShard(Path topDirectory, String shard) throws IOException; + + String fileStoreName(Path path) throws IOException; + + String fileStoreType(Path path) throws IOException; + + VerifiedArtifact createStageAndForce( + Path topDirectory, + String shard, + String fileName, + String operationId, + long maximumBytes, + PayloadWriter writer) + throws IOException; + + VerifiedArtifact inspect( + ArtifactKind kind, + Path topDirectory, + String shard, + String fileName, + String identity, + long maximumBytes) + throws IOException; + + void createHardLink(Path link, Path existing) throws IOException; + + void forceDirectory(Path directory) throws IOException; + + void cleanupCreatedStage( + Path topDirectory, + String shard, + String fileName, + String expectedFileKey, + Throwable originalFailure) + throws IOException; + + boolean deleteExact(Path topDirectory, String shard, String fileName, VerifiedArtifact expected) + throws IOException; + } + + static final class LocalPersistentPayloadException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final FailureKind kind; + + LocalPersistentPayloadException(FailureKind kind, String message) { + super(message); + this.kind = Objects.requireNonNull(kind, "kind must be non-null"); + } + + LocalPersistentPayloadException(FailureKind kind, String message, Throwable cause) { + super(message, cause); + this.kind = Objects.requireNonNull(kind, "kind must be non-null"); + } + + FailureKind kind() { + return kind; + } + } + + private static final class StageWriteFailureException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final Throwable original; + private final String createdFileKey; + + private StageWriteFailureException(Throwable original, String createdFileKey) { + super(null, null, false, false); + this.original = Objects.requireNonNull(original, "original must be non-null"); + this.createdFileKey = createdFileKey; + } + + private Throwable original() { + return original; + } + + private String createdFileKey() { + return createdFileKey; + } + } + + private enum SystemPayloadAccess implements PayloadAccess { + INSTANCE; + + @Override + public ShardAttributes statShard(Path topDirectory, String shard) throws IOException { + Path shardPath = topDirectory.resolve(shard); + PosixFileAttributes attributes; + try { + attributes = + Files.readAttributes(shardPath, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException ignored) { + return null; + } + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw failure(FailureKind.INTEGRITY, "payload shard is not a no-follow directory", null); + } + return shardAttributes(shardPath, attributes); + } + + @Override + public ShardAttributes createPrivateShard(Path topDirectory, String shard) throws IOException { + Path shardPath = + Files.createDirectory( + topDirectory.resolve(shard), + PosixFilePermissions.asFileAttribute(PRIVATE_DIRECTORY_PERMISSIONS)); + Files.setPosixFilePermissions(shardPath, PRIVATE_DIRECTORY_PERMISSIONS); + PosixFileAttributes attributes = + Files.readAttributes(shardPath, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + return shardAttributes(shardPath, attributes); + } + + @Override + public String fileStoreName(Path path) throws IOException { + return Files.getFileStore(path).name(); + } + + @Override + public String fileStoreType(Path path) throws IOException { + return Files.getFileStore(path).type(); + } + + @Override + public VerifiedArtifact createStageAndForce( + Path topDirectory, + String shard, + String fileName, + String operationId, + long maximumBytes, + PayloadWriter writer) + throws IOException { + String createdFileKey = null; + try (SecureDirectoryStream shardStream = openSecureDirectory(topDirectory, shard); + SeekableByteChannel channel = + shardStream.newByteChannel( + Path.of(fileName), + CREATE_STAGE_OPTIONS, + PosixFilePermissions.asFileAttribute(PRIVATE_FILE_PERMISSIONS))) { + PosixFileAttributes created = readPosix(shardStream, fileName); + requireRegular(created); + createdFileKey = fileKey(created.fileKey()); + if (!(channel instanceof FileChannel fileChannel)) { + throw new IOException("payload filesystem does not expose a force-capable file channel"); + } + OutputStream output = + new NonClosingBoundedOutputStream(Channels.newOutputStream(fileChannel), maximumBytes); + writer.write(output); + output.flush(); + fileChannel.force(true); + } catch (FileAlreadyExistsException exception) { + throw exception; + } catch (Throwable original) { + throw new StageWriteFailureException(original, createdFileKey); + } + try { + VerifiedArtifact artifact = + inspect(ArtifactKind.STAGE, topDirectory, shard, fileName, operationId, maximumBytes); + if (artifact == null) { + throw new IOException("forced payload stage disappeared"); + } + if (!artifact.fileKey().equals(createdFileKey)) { + throw failure(FailureKind.INTEGRITY, "forced payload stage identity changed", null); + } + return artifact; + } catch (Throwable original) { + throw new StageWriteFailureException(original, createdFileKey); + } + } + + @Override + public VerifiedArtifact inspect( + ArtifactKind kind, + Path topDirectory, + String shard, + String fileName, + String identity, + long maximumBytes) + throws IOException { + try (SecureDirectoryStream directory = openSecureDirectory(topDirectory, shard)) { + PosixFileAttributes before; + try { + before = readPosix(directory, fileName); + } catch (NoSuchFileException ignored) { + return null; + } + requireRegular(before); + if (before.size() > maximumBytes) { + throw failure(FailureKind.CAPACITY, "payload artifact exceeds configured bound", null); + } + MessageDigest digest = sha256(); + long bytesRead = 0; + try (SeekableByteChannel channel = + directory.newByteChannel(Path.of(fileName), READ_OPTIONS)) { + ByteBuffer buffer = ByteBuffer.allocate(8192); + while (true) { + int read = channel.read(buffer); + if (read < 0) { + break; + } + if (read == 0) { + continue; + } + if (bytesRead > maximumBytes - read) { + throw failure( + FailureKind.CAPACITY, "payload artifact exceeds configured bound", null); + } + bytesRead += read; + buffer.flip(); + digest.update(buffer); + buffer.clear(); + } + } + PosixFileAttributes after = readPosix(directory, fileName); + requireRegular(after); + String beforeFileKey = fileKey(before.fileKey()); + if (!beforeFileKey.equals(fileKey(after.fileKey())) + || before.size() != after.size() + || before.size() != bytesRead + || !before.lastModifiedTime().equals(after.lastModifiedTime())) { + throw failure(FailureKind.INTEGRITY, "payload artifact changed during inspection", null); + } + Path artifactPath = + shard == null + ? topDirectory.resolve(fileName) + : topDirectory.resolve(shard).resolve(fileName); + return new VerifiedArtifact( + kind, + identity, + shard, + fileName, + bytesRead, + HEX.formatHex(digest.digest()), + beforeFileKey, + after.owner().getName(), + after.permissions(), + Files.getFileStore(artifactPath).name(), + Files.getFileStore(artifactPath).type()); + } + } + + @Override + public void createHardLink(Path link, Path existing) throws IOException { + Files.createLink(link, existing); + } + + @Override + public void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + @Override + public void cleanupCreatedStage( + Path topDirectory, + String shard, + String fileName, + String expectedFileKey, + Throwable originalFailure) + throws IOException { + try (SecureDirectoryStream directory = openSecureDirectory(topDirectory, shard)) { + PosixFileAttributes attributes; + try { + attributes = readPosix(directory, fileName); + } catch (NoSuchFileException ignored) { + return; + } + requireRegular(attributes); + if (!expectedFileKey.equals(fileKey(attributes.fileKey()))) { + throw failure( + FailureKind.INTEGRITY, "refusing cleanup of a replaced payload stage", null); + } + directory.deleteFile(Path.of(fileName)); + } + forceDirectory(topDirectory.resolve(shard)); + } + + @Override + public boolean deleteExact( + Path topDirectory, String shard, String fileName, VerifiedArtifact expected) + throws IOException { + try (SecureDirectoryStream directory = openSecureDirectory(topDirectory, shard)) { + PosixFileAttributes attributes; + try { + attributes = readPosix(directory, fileName); + } catch (NoSuchFileException ignored) { + return false; + } + requireRegular(attributes); + if (!expected.fileKey().equals(fileKey(attributes.fileKey())) + || expected.byteSize() != attributes.size()) { + throw failure(FailureKind.INTEGRITY, "refusing delete of a replaced payload stage", null); + } + directory.deleteFile(Path.of(fileName)); + return true; + } + } + + private static ShardAttributes shardAttributes(Path path, PosixFileAttributes attributes) + throws IOException { + return new ShardAttributes( + fileKey(attributes.fileKey()), + attributes.owner().getName(), + attributes.permissions(), + Files.getFileStore(path).name(), + Files.getFileStore(path).type()); + } + + @SuppressWarnings("StreamResourceLeak") + private static SecureDirectoryStream openSecureDirectory(Path topDirectory, String shard) + throws IOException { + DirectoryStream top = Files.newDirectoryStream(topDirectory); + if (!(top instanceof SecureDirectoryStream secureTop)) { + top.close(); + throw failure( + FailureKind.INTEGRITY, "SecureDirectoryStream is unavailable for payload root", null); + } + if (shard == null) { + return secureTop; + } + SecureDirectoryStream child; + try { + child = secureTop.newDirectoryStream(Path.of(shard), LinkOption.NOFOLLOW_LINKS); + } catch (IOException | RuntimeException | Error openFailure) { + try { + secureTop.close(); + } catch (IOException | RuntimeException | Error closeFailure) { + openFailure.addSuppressed(closeFailure); + } + throw openFailure; + } + try { + secureTop.close(); + return child; + } catch (IOException | RuntimeException | Error closeFailure) { + try { + child.close(); + } catch (IOException | RuntimeException | Error childCloseFailure) { + closeFailure.addSuppressed(childCloseFailure); + } + throw closeFailure; + } + } + + private static PosixFileAttributes readPosix( + SecureDirectoryStream directory, String fileName) throws IOException { + PosixFileAttributeView view = + directory.getFileAttributeView( + Path.of(fileName), PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + if (view == null) { + throw failure(FailureKind.INTEGRITY, "POSIX payload attributes are unavailable", null); + } + return view.readAttributes(); + } + + private static void requireRegular(PosixFileAttributes attributes) { + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw failure( + FailureKind.INTEGRITY, "payload artifact is not a no-follow regular file", null); + } + } + } + + private static final class NonClosingBoundedOutputStream extends FilterOutputStream { + + private final long maximumBytes; + private long written; + + private NonClosingBoundedOutputStream(OutputStream delegate, long maximumBytes) { + super(delegate); + this.maximumBytes = maximumBytes; + } + + @Override + public void write(int value) throws IOException { + requireCapacity(1); + out.write(value); + written++; + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + Objects.checkFromIndexSize(offset, length, bytes.length); + requireCapacity(length); + out.write(bytes, offset, length); + written += length; + } + + @Override + public void close() throws IOException { + flush(); + } + + private void requireCapacity(int nextBytes) { + if (written > maximumBytes - nextBytes) { + throw failure(FailureKind.CAPACITY, "payload byte limit exceeded", null); + } + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java new file mode 100644 index 0000000..c0ab088 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java @@ -0,0 +1,896 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.FileVersion; +import dev.caskeleton.application.filepublication.PublishedFileReference; +import dev.caskeleton.application.filepublication.TabularRowProducer; +import dev.caskeleton.application.filepublication.TabularRowSink; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Qualified local-persistent R2 provider with direct operation recovery. */ +final class LocalPersistentPublicationProvider implements FilePublicationProvider { + + private static final String FORMAT_PROFILE = "csv-rfc4180-v1"; + private static final String MEDIA_TYPE = "text/csv"; + private static final String CHARSET = StandardCharsets.UTF_8.name(); + private static final HexFormat HEX = HexFormat.of(); + + private final Map runtimes; + private final Clock clock; + private final FileIdGenerator fileIds; + private final FileserverControlRecordCodec codec; + private final LocalPersistentRecoveryVerifier verifier; + + LocalPersistentPublicationProvider( + Map runtimes, Clock clock, FileIdGenerator fileIds) { + this( + runtimes, + clock, + fileIds, + new FileserverControlRecordCodec(), + new LocalPersistentRecoveryVerifier()); + } + + LocalPersistentPublicationProvider( + Map runtimes, + Clock clock, + FileIdGenerator fileIds, + FileserverControlRecordCodec codec, + LocalPersistentRecoveryVerifier verifier) { + this.runtimes = Map.copyOf(Objects.requireNonNull(runtimes, "runtimes must be non-null")); + if (this.runtimes.isEmpty()) { + throw new IllegalArgumentException("local-persistent provider requires a destination"); + } + String providerId = null; + Path providerRoot = null; + LocalPersistentControlPlane sharedControlPlane = null; + LocalPersistentPayloadOperations sharedPayload = null; + for (Map.Entry entry : this.runtimes.entrySet()) { + FileDestinationId destinationId = + Objects.requireNonNull(entry.getKey(), "destinationId must be non-null"); + DestinationRuntime runtime = + Objects.requireNonNull(entry.getValue(), "destination runtime must be non-null"); + if (!destinationId.equals(runtime.destination().destinationId())) { + throw new IllegalArgumentException("destination runtime key does not match descriptor"); + } + if (!runtime.destination().rootDirectory().equals(runtime.controlPlane().root()) + || !runtime.destination().rootDirectory().equals(runtime.payload().root())) { + throw new IllegalArgumentException( + "destination descriptor and runtime root must be identical"); + } + if (providerId == null) { + providerId = runtime.destination().providerId(); + providerRoot = runtime.destination().rootDirectory(); + sharedControlPlane = runtime.controlPlane(); + sharedPayload = runtime.payload(); + } else if (!providerId.equals(runtime.destination().providerId()) + || !providerRoot.equals(runtime.destination().rootDirectory()) + || sharedControlPlane != runtime.controlPlane() + || sharedPayload != runtime.payload()) { + throw new IllegalArgumentException( + "one provider instance requires one provider ID, root, and shared runtime"); + } + } + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.fileIds = Objects.requireNonNull(fileIds, "fileIds must be non-null"); + this.codec = Objects.requireNonNull(codec, "codec must be non-null"); + this.verifier = Objects.requireNonNull(verifier, "verifier must be non-null"); + } + + @Override + public FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer) { + DestinationRuntime runtime = validateAndSelect(request, producer); + String requestFingerprint; + try { + requestFingerprint = FilePublishRequestFingerprint.calculate(request); + } catch (IllegalArgumentException exception) { + throw invalidRequest("file publication request is not canonical UTF-8", exception); + } + LocalPersistentControlPlane controlPlane = runtime.controlPlane(); + try (LocalPersistentControlPlane.OperationLock ignored = + controlPlane.acquireOperationLock(request.operationId().value())) { + Optional existing = + controlPlane.findStoredOperation(request.operationId().value()); + if (existing.isPresent()) { + return recoverStored(runtime, request, requestFingerprint, existing.orElseThrow()); + } + return publishNew(runtime, request, requestFingerprint, producer); + } catch (FilePublicationException exception) { + throw exception; + } catch (LocalPersistentControlPlane.LocalPersistentControlPlaneException exception) { + throw mapControlFailure(exception); + } catch (LocalPersistentPayloadOperations.LocalPersistentPayloadException exception) { + throw mapPayloadFailure(exception); + } + } + + private DestinationRuntime validateAndSelect( + FilePublishRequest request, TabularRowProducer producer) { + if (request == null) { + throw invalidRequest("file publication request must be non-null", null); + } + if (producer == null) { + throw invalidRequest("file publication producer must be non-null", null); + } + DestinationRuntime runtime = runtimes.get(request.destinationId()); + if (runtime == null) { + throw invalidRequest("file publication destination is not configured", null); + } + if (!FORMAT_PROFILE.equals(request.formatProfileId())) { + throw invalidRequest("file publication format profile is not supported", null); + } + try { + FilePublicationCanonicalDigests.schemaDigest(request.schema()); + } catch (IllegalArgumentException exception) { + throw invalidRequest("file publication schema is not canonical UTF-8", exception); + } + return runtime; + } + + private FilePublishReceipt publishNew( + DestinationRuntime runtime, + FilePublishRequest request, + String requestFingerprint, + TabularRowProducer producer) { + String fileId = fileIds.nextFileId(); + FileserverControlRecordCodec.requireFileId(fileId); + String publishedFileName = LocalPersistentRecoveryVerifier.generatedFileName(fileId); + String stageFileName = + LocalPersistentPayloadOperations.stageFileName(request.operationId().value()); + CompiledFileDestination destination = runtime.destination(); + DurablePublicationRecord writing = + new DurablePublicationRecord( + DurablePublicationRecord.CURRENT_SCHEMA_VERSION, + 1, + DurablePublicationRecord.State.WRITING, + request.operationId().value(), + requestFingerprint, + destination.effectivePolicyRevision(), + destination.effectivePolicyDigest(), + destination.destinationId().value(), + destination.providerId(), + fileId, + destination.routeToken(), + publishedFileName, + stageFileName, + 0, + 0, + 0, + "", + 0, + "", + "", + clock.instant(), + null, + null, + "", + ""); + runtime.controlPlane().storeOperation(writing); + + StreamingCsvEncoder.Stats[] stats = new StreamingCsvEncoder.Stats[1]; + LocalPersistentPayloadOperations.VerifiedArtifact staged = null; + try { + staged = + runtime + .payload() + .stage( + writing.operationId(), + writing.stageFileName(), + destination.maximumEncodedBytes(), + output -> + stats[0] = + streamRequest( + request, + producer, + output, + destination.maximumRows(), + destination.maximumEncodedBytes())); + if (stats[0] == null + || stats[0].byteSize() != staged.byteSize() + || !stats[0].sha256().equals(staged.sha256())) { + throw new IllegalStateException("streaming encoder statistics do not match staged bytes"); + } + } catch (Throwable original) { + if (staged != null) { + try { + runtime.payload().deleteStageExact(staged); + } catch (Throwable cleanupFailure) { + if (cleanupFailure != original) { + original.addSuppressed(cleanupFailure); + } + } + } + quarantinePreservingOriginal(runtime.controlPlane(), writing, "STAGE_FAILED", original); + throwOriginal(original); + throw new AssertionError("unreachable"); + } + + DurablePublicationRecord sealed = + transition( + writing, + DurablePublicationRecord.State.SEALED, + staged.byteSize(), + stats[0].rowCount(), + request.schema().columns().size(), + staged.sha256(), + stats[0].formulaMitigatedCount(), + "", + "", + clock.instant(), + null, + "", + ""); + runtime.controlPlane().storeOperation(sealed); + return recoverR2(runtime, request, requestFingerprint, sealed); + } + + private static StreamingCsvEncoder.Stats streamRequest( + FilePublishRequest request, + TabularRowProducer producer, + OutputStream output, + long maximumRows, + long maximumEncodedBytes) { + StreamingCsvEncoder encoder = + new StreamingCsvEncoder( + request.schema(), output, sha256(), maximumRows, maximumEncodedBytes); + encoder.writeHeader(); + TabularRowSink sink = + new TabularRowSink() { + @Override + public void write(dev.caskeleton.application.filepublication.TabularRow row) { + encoder.write(row); + } + + @Override + public void checkpoint() { + encoder.checkpoint(); + } + }; + producer.produce(sink); + encoder.checkpoint(); + return encoder.finish(); + } + + private FilePublishReceipt recoverStored( + DestinationRuntime runtime, + FilePublishRequest request, + String requestFingerprint, + LocalPersistentControlPlane.StoredOperationRecord stored) { + if (stored instanceof LocalPersistentControlPlane.R1StoredOperationRecord r1) { + return restoreR1(runtime, request, requestFingerprint, r1.record()); + } + if (stored instanceof LocalPersistentControlPlane.R2StoredOperationRecord r2) { + return recoverR2(runtime, request, requestFingerprint, r2.record()); + } + throw new IllegalStateException("unsupported stored operation record"); + } + + private FilePublishReceipt restoreR1( + DestinationRuntime runtime, + FilePublishRequest request, + String requestFingerprint, + LocalPublicationJournalRecord record) { + if (!record.requestFingerprint().equals(requestFingerprint)) { + throw conflict("operation ID was already used for a different request", null); + } + LocalPersistentPayloadOperations.VerifiedArtifact artifact = + runtime + .payload() + .inspectLegacyRootArtifact(record.publishedFileName(), exactBound(record.byteSize())) + .orElseThrow(() -> indeterminate("legacy publication artifact is unavailable", null)); + if (artifact.byteSize() != record.byteSize() || !artifact.sha256().equals(record.sha256())) { + throw indeterminate("legacy publication artifact does not match its journal", null); + } + PublicationGuarantee publicationGuarantee; + try { + publicationGuarantee = PublicationGuarantee.valueOf(record.publicationGuarantee()); + } catch (IllegalArgumentException exception) { + throw indeterminate("legacy publication guarantee is unsupported", exception); + } + String operationToken = sha256Hex(record.operationId()).substring(0, 24); + return new FilePublishReceipt( + new FilePublishOperationId(record.operationId()), + new PublishedFileReference( + "filepub:" + request.destinationId().value() + ":" + operationToken), + request.destinationId(), + record.publishedFileName(), + new FileVersion(record.sha256()), + request.formatProfileId(), + MEDIA_TYPE, + CHARSET, + record.byteSize(), + record.rowCount(), + record.columnCount(), + record.sha256(), + Instant.parse(record.publishedAt()), + publicationGuarantee, + DurabilityGuarantee.PROCESS_LOCAL_SYNC, + record.formulaMitigatedCount()); + } + + private FilePublishReceipt recoverR2( + DestinationRuntime runtime, + FilePublishRequest request, + String requestFingerprint, + DurablePublicationRecord startingOperation) { + DurablePublicationRecord operation = startingOperation; + if (!operation.requestFingerprint().equals(requestFingerprint)) { + throw conflict("operation ID was already used for a different request", null); + } + try { + verifier.requireOperationMatches( + operation, request, runtime.destination(), requestFingerprint); + } catch (IllegalArgumentException exception) { + throw indeterminate("durable operation policy or identity does not match", exception); + } + + while (true) { + try { + switch (operation.state()) { + case WRITING -> { + throw quarantineAndIndeterminate( + runtime.controlPlane(), + operation, + "UNSEALED_WRITING", + "publication was interrupted before payload sealing", + null); + } + case QUARANTINED -> throw indeterminate("publication is quarantined", null); + case SEALED -> operation = resumeData(runtime, operation); + case DATA_PUBLISHED -> operation = resumeManifest(runtime, request, operation); + case MANIFEST_PUBLISHED -> operation = resumeReference(runtime, request, operation); + case REFERENCE_PUBLISHED -> operation = completeTerminal(runtime, request, operation); + case PUBLISHED -> { + return restoreTerminal(runtime, request, operation); + } + default -> throw new IllegalStateException("unsupported durable publication state"); + } + } catch (RecoveryIntegrityException exception) { + throw quarantineAndIndeterminate( + runtime.controlPlane(), + operation, + "RECOVERY_INTEGRITY", + "publication recovery evidence is inconsistent", + exception); + } catch (LocalPersistentPayloadOperations.LocalPersistentPayloadException exception) { + if (exception.kind() == LocalPersistentPayloadOperations.FailureKind.INTEGRITY + || exception.kind() == LocalPersistentPayloadOperations.FailureKind.CAPACITY) { + throw quarantineAndIndeterminate( + runtime.controlPlane(), + operation, + "PAYLOAD_INTEGRITY", + "publication payload evidence is inconsistent", + exception); + } + throw indeterminate("publication payload recovery is indeterminate", exception); + } + } + } + + private DurablePublicationRecord resumeData( + DestinationRuntime runtime, DurablePublicationRecord operation) { + long bound = exactBound(operation.byteSize()); + Optional stage = + runtime.payload().inspectStage(operation.operationId(), operation.stageFileName(), bound); + Optional data = + runtime.payload().inspectData(operation.fileId(), operation.publishedFileName(), bound); + stage.ifPresent(artifact -> requireArtifact(operation, artifact)); + data.ifPresent(artifact -> requireArtifact(operation, artifact)); + if (stage.isEmpty() && data.isEmpty()) { + throw new RecoveryIntegrityException("sealed payload has no recoverable artifact"); + } + + LocalPersistentPayloadOperations.VerifiedArtifact published; + if (data.isPresent()) { + published = data.orElseThrow(); + if (stage.isPresent() && !stage.orElseThrow().fileKey().equals(published.fileKey())) { + throw new RecoveryIntegrityException("stage and data are not the same exclusive hard-link"); + } + runtime.payload().forceDataDirectory(operation.fileId()); + } else { + published = + runtime + .payload() + .publishData( + stage.orElseThrow(), operation.fileId(), operation.publishedFileName(), bound); + requireArtifact(operation, published); + } + DurablePublicationRecord advanced = + transition( + operation, + DurablePublicationRecord.State.DATA_PUBLISHED, + operation.byteSize(), + operation.rowCount(), + operation.columnCount(), + operation.sha256(), + operation.formulaMitigatedCount(), + "", + "", + operation.sealedAt(), + null, + "", + ""); + runtime.controlPlane().storeOperation(advanced); + if (stage.isPresent()) { + runtime.payload().deleteStageExact(stage.orElseThrow()); + } + return advanced; + } + + private DurablePublicationRecord resumeManifest( + DestinationRuntime runtime, FilePublishRequest request, DurablePublicationRecord operation) { + LocalPersistentPayloadOperations.VerifiedArtifact data = + requireMatchingData(runtime, operation); + cleanupResidualStage(runtime, operation, data); + PublishedFileReference opaqueReference = verifier.reference(operation); + Optional existing = + runtime.controlPlane().findManifest(operation.fileId()); + PrivateFileManifest manifest; + if (existing.isPresent()) { + manifest = existing.orElseThrow(); + requireManifest(runtime, request, operation, manifest, opaqueReference); + } else { + manifest = + new PrivateFileManifest( + PrivateFileManifest.CURRENT_SCHEMA_VERSION, + operation.operationId(), + operation.fileId(), + operation.providerId(), + opaqueReference.value(), + operation.requestFingerprint(), + operation.destinationId(), + request.schema().schemaId(), + request.schema().version(), + FilePublicationCanonicalDigests.schemaDigest(request.schema()), + request.formatProfileId(), + runtime.destination().formatPolicyDigest(), + operation.effectivePolicyRevision(), + operation.effectivePolicyDigest(), + operation.publishedFileName(), + operation.sha256(), + MEDIA_TYPE, + CHARSET, + operation.byteSize(), + operation.rowCount(), + operation.columnCount(), + operation.sha256(), + operation.formulaMitigatedCount(), + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + operation.publishedFileName(), + operation.createdAt(), + clock.instant()); + } + runtime.controlPlane().storeManifest(manifest); + String manifestDigest = verifier.canonicalManifestDigest(manifest); + DurablePublicationRecord advanced = + transition( + operation, + DurablePublicationRecord.State.MANIFEST_PUBLISHED, + operation.byteSize(), + operation.rowCount(), + operation.columnCount(), + operation.sha256(), + operation.formulaMitigatedCount(), + manifestDigest, + "", + operation.sealedAt(), + null, + "", + ""); + runtime.controlPlane().storeOperation(advanced); + return advanced; + } + + private DurablePublicationRecord resumeReference( + DestinationRuntime runtime, FilePublishRequest request, DurablePublicationRecord operation) { + LocalPersistentPayloadOperations.VerifiedArtifact data = + requireMatchingData(runtime, operation); + cleanupResidualStage(runtime, operation, data); + PrivateFileManifest manifest = + runtime + .controlPlane() + .findManifest(operation.fileId()) + .orElseThrow(() -> new RecoveryIntegrityException("published manifest is missing")); + PublishedFileReference opaqueReference = verifier.reference(operation); + requireManifest(runtime, request, operation, manifest, opaqueReference); + String manifestDigest = verifier.canonicalManifestDigest(manifest); + if (!manifestDigest.equals(operation.manifestDigest())) { + throw new RecoveryIntegrityException("operation manifest digest does not match"); + } + Optional existing = + runtime.controlPlane().findReference(operation.fileId()); + PublishedReferenceRecord reference; + if (existing.isPresent()) { + reference = existing.orElseThrow(); + requireReference(operation, manifest, reference, opaqueReference, manifestDigest); + } else { + reference = + new PublishedReferenceRecord( + PublishedReferenceRecord.CURRENT_SCHEMA_VERSION, + operation.fileId(), + operation.routeToken(), + opaqueReference.value(), + operation.operationId(), + operation.sha256(), + manifestDigest, + operation.publishedFileName(), + operation.destinationId(), + operation.providerId(), + operation.publishedFileName(), + MEDIA_TYPE, + CHARSET, + operation.byteSize(), + operation.sha256(), + manifest.publishedAt()); + } + runtime.controlPlane().storeReference(reference); + String referenceDigest = verifier.canonicalReferenceDigest(reference); + DurablePublicationRecord advanced = + transition( + operation, + DurablePublicationRecord.State.REFERENCE_PUBLISHED, + operation.byteSize(), + operation.rowCount(), + operation.columnCount(), + operation.sha256(), + operation.formulaMitigatedCount(), + manifestDigest, + referenceDigest, + operation.sealedAt(), + null, + "", + ""); + runtime.controlPlane().storeOperation(advanced); + return advanced; + } + + private DurablePublicationRecord completeTerminal( + DestinationRuntime runtime, FilePublishRequest request, DurablePublicationRecord operation) { + LocalPersistentPayloadOperations.VerifiedArtifact data = + requireMatchingData(runtime, operation); + cleanupResidualStage(runtime, operation, data); + PrivateFileManifest manifest = + runtime + .controlPlane() + .findManifest(operation.fileId()) + .orElseThrow(() -> new RecoveryIntegrityException("published manifest is missing")); + PublishedReferenceRecord reference = + runtime + .controlPlane() + .findReference(operation.fileId()) + .orElseThrow(() -> new RecoveryIntegrityException("published reference is missing")); + PublishedFileReference opaqueReference = verifier.reference(operation); + requireManifest(runtime, request, operation, manifest, opaqueReference); + String manifestDigest = verifier.canonicalManifestDigest(manifest); + requireReference(operation, manifest, reference, opaqueReference, manifestDigest); + String referenceDigest = verifier.canonicalReferenceDigest(reference); + if (!operation.manifestDigest().equals(manifestDigest) + || !operation.referenceDigest().equals(referenceDigest)) { + throw new RecoveryIntegrityException("operation metadata digest does not match"); + } + FilePublishReceipt receipt = + verifier.expectedReceipt(operation, request, manifest, opaqueReference); + DurablePublicationRecord terminal = + transition( + operation, + DurablePublicationRecord.State.PUBLISHED, + operation.byteSize(), + operation.rowCount(), + operation.columnCount(), + operation.sha256(), + operation.formulaMitigatedCount(), + manifestDigest, + referenceDigest, + operation.sealedAt(), + manifest.publishedAt(), + "", + codec.encodeReceiptSnapshot(receipt)); + runtime.controlPlane().storeOperation(terminal); + return terminal; + } + + private FilePublishReceipt restoreTerminal( + DestinationRuntime runtime, FilePublishRequest request, DurablePublicationRecord operation) { + try { + requireMatchingData(runtime, operation); + PrivateFileManifest manifest = + runtime + .controlPlane() + .findManifest(operation.fileId()) + .orElseThrow(() -> new RecoveryIntegrityException("terminal manifest is missing")); + PublishedReferenceRecord reference = + runtime + .controlPlane() + .findReference(operation.fileId()) + .orElseThrow(() -> new RecoveryIntegrityException("terminal reference is missing")); + PublishedFileReference opaqueReference = verifier.reference(operation); + requireManifest(runtime, request, operation, manifest, opaqueReference); + String manifestDigest = verifier.canonicalManifestDigest(manifest); + requireReference(operation, manifest, reference, opaqueReference, manifestDigest); + return verifier.requireTerminalReceipt(operation, request, manifest, reference); + } catch (IllegalArgumentException | RecoveryIntegrityException exception) { + throw indeterminate("terminal publication evidence does not match", exception); + } + } + + private LocalPersistentPayloadOperations.VerifiedArtifact requireMatchingData( + DestinationRuntime runtime, DurablePublicationRecord operation) { + LocalPersistentPayloadOperations.VerifiedArtifact data = + runtime + .payload() + .inspectData( + operation.fileId(), operation.publishedFileName(), exactBound(operation.byteSize())) + .orElseThrow(() -> new RecoveryIntegrityException("published data is missing")); + requireArtifact(operation, data); + return data; + } + + private void cleanupResidualStage( + DestinationRuntime runtime, + DurablePublicationRecord operation, + LocalPersistentPayloadOperations.VerifiedArtifact data) { + Optional residual = + runtime + .payload() + .inspectStage( + operation.operationId(), + operation.stageFileName(), + exactBound(operation.byteSize())); + if (residual.isEmpty()) { + return; + } + LocalPersistentPayloadOperations.VerifiedArtifact staged = residual.orElseThrow(); + requireArtifact(operation, staged); + if (!staged.fileKey().equals(data.fileKey())) { + throw new RecoveryIntegrityException("residual stage is not the published data hard-link"); + } + runtime.payload().deleteStageExact(staged); + } + + private void requireArtifact( + DurablePublicationRecord operation, + LocalPersistentPayloadOperations.VerifiedArtifact artifact) { + try { + verifier.requireArtifact(operation, artifact); + } catch (IllegalArgumentException exception) { + throw new RecoveryIntegrityException("payload artifact does not match", exception); + } + } + + private void requireManifest( + DestinationRuntime runtime, + FilePublishRequest request, + DurablePublicationRecord operation, + PrivateFileManifest manifest, + PublishedFileReference reference) { + try { + verifier.requireManifest(operation, request, runtime.destination(), manifest, reference); + } catch (IllegalArgumentException exception) { + throw new RecoveryIntegrityException("private manifest does not match", exception); + } + } + + private void requireReference( + DurablePublicationRecord operation, + PrivateFileManifest manifest, + PublishedReferenceRecord reference, + PublishedFileReference opaqueReference, + String manifestDigest) { + try { + verifier.requireReference(operation, manifest, reference, opaqueReference, manifestDigest); + } catch (IllegalArgumentException exception) { + throw new RecoveryIntegrityException("reference index does not match", exception); + } + } + + private static DurablePublicationRecord transition( + DurablePublicationRecord current, + DurablePublicationRecord.State state, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + String manifestDigest, + String referenceDigest, + Instant sealedAt, + Instant publishedAt, + String lastFailureCode, + String receiptSnapshot) { + return new DurablePublicationRecord( + current.schemaVersion(), + current.stateRevision() + 1, + state, + current.operationId(), + current.requestFingerprint(), + current.effectivePolicyRevision(), + current.effectivePolicyDigest(), + current.destinationId(), + current.providerId(), + current.fileId(), + current.routeToken(), + current.publishedFileName(), + current.stageFileName(), + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + manifestDigest, + referenceDigest, + current.createdAt(), + sealedAt, + publishedAt, + lastFailureCode, + receiptSnapshot); + } + + private static DurablePublicationRecord quarantined( + DurablePublicationRecord current, String failureCode) { + return transition( + current, + DurablePublicationRecord.State.QUARANTINED, + current.byteSize(), + current.rowCount(), + current.columnCount(), + current.sha256(), + current.formulaMitigatedCount(), + current.manifestDigest(), + current.referenceDigest(), + current.sealedAt(), + null, + failureCode, + ""); + } + + private static void quarantinePreservingOriginal( + LocalPersistentControlPlane controlPlane, + DurablePublicationRecord operation, + String failureCode, + Throwable original) { + try { + controlPlane.storeOperation(quarantined(operation, failureCode)); + } catch (Throwable controlFailure) { + if (controlFailure != original) { + original.addSuppressed(controlFailure); + } + } + } + + private static FilePublicationException quarantineAndIndeterminate( + LocalPersistentControlPlane controlPlane, + DurablePublicationRecord operation, + String failureCode, + String message, + Throwable cause) { + FilePublicationException failure = indeterminate(message, cause); + if (operation.state() != DurablePublicationRecord.State.PUBLISHED + && operation.state() != DurablePublicationRecord.State.QUARANTINED) { + try { + controlPlane.storeOperation(quarantined(operation, failureCode)); + } catch (Throwable quarantineFailure) { + failure.addSuppressed(quarantineFailure); + } + } + return failure; + } + + private static long exactBound(long byteSize) { + if (byteSize < 1) { + throw new RecoveryIntegrityException("sealed artifact byte size must be positive"); + } + return byteSize; + } + + private static FilePublicationException mapControlFailure( + LocalPersistentControlPlane.LocalPersistentControlPlaneException exception) { + if (exception.kind() == LocalPersistentControlPlane.FailureKind.CONFLICT) { + return conflict("file publication control conflict", exception); + } + return indeterminate("file publication control state is indeterminate", exception); + } + + private static FilePublicationException mapPayloadFailure( + LocalPersistentPayloadOperations.LocalPersistentPayloadException exception) { + return switch (exception.kind()) { + case CAPACITY -> + new FilePublicationException( + FilePublicationException.Reason.CAPACITY_EXCEEDED, + "file publication capacity was exceeded", + exception); + case CONFLICT -> conflict("file publication target conflict", exception); + case INTEGRITY, STORAGE, INDETERMINATE -> + indeterminate("file publication payload state is indeterminate", exception); + }; + } + + private static FilePublicationException invalidRequest(String message, Throwable cause) { + return cause == null + ? new FilePublicationException(FilePublicationException.Reason.INVALID_REQUEST, message) + : new FilePublicationException( + FilePublicationException.Reason.INVALID_REQUEST, message, cause); + } + + private static FilePublicationException conflict(String message, Throwable cause) { + return cause == null + ? new FilePublicationException(FilePublicationException.Reason.CONFLICT, message) + : new FilePublicationException(FilePublicationException.Reason.CONFLICT, message, cause); + } + + private static FilePublicationException indeterminate(String message, Throwable cause) { + return cause == null + ? new FilePublicationException( + FilePublicationException.Reason.PUBLISH_INDETERMINATE, message) + : new FilePublicationException( + FilePublicationException.Reason.PUBLISH_INDETERMINATE, message, cause); + } + + private static void throwOriginal(Throwable original) { + if (original instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (original instanceof Error error) { + throw error; + } + throw indeterminate("file publication producer failed", original); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static String sha256Hex(String value) { + return HEX.formatHex(sha256().digest(value.getBytes(StandardCharsets.UTF_8))); + } + + record DestinationRuntime( + CompiledFileDestination destination, + LocalPersistentControlPlane controlPlane, + LocalPersistentPayloadOperations payload) { + + DestinationRuntime { + Objects.requireNonNull(destination, "destination must be non-null"); + Objects.requireNonNull(controlPlane, "controlPlane must be non-null"); + Objects.requireNonNull(payload, "payload must be non-null"); + } + } + + @FunctionalInterface + interface FileIdGenerator { + String nextFileId(); + } + + private static final class RecoveryIntegrityException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private RecoveryIntegrityException(String message) { + super(message); + } + + private RecoveryIntegrityException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java new file mode 100644 index 0000000..4a43965 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java @@ -0,0 +1,210 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.FileVersion; +import dev.caskeleton.application.filepublication.PublishedFileReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** Full-field verifier used before any R2 recovery transition or terminal receipt restoration. */ +final class LocalPersistentRecoveryVerifier { + + private static final String MEDIA_TYPE = "text/csv"; + private static final String CHARSET = StandardCharsets.UTF_8.name(); + private static final HexFormat HEX = HexFormat.of(); + + private final FileserverControlRecordCodec codec; + private final R2PublishedReferenceCodec referenceCodec; + + LocalPersistentRecoveryVerifier() { + this(new FileserverControlRecordCodec(), new R2PublishedReferenceCodec()); + } + + LocalPersistentRecoveryVerifier( + FileserverControlRecordCodec codec, R2PublishedReferenceCodec referenceCodec) { + this.codec = Objects.requireNonNull(codec, "codec must be non-null"); + this.referenceCodec = Objects.requireNonNull(referenceCodec, "referenceCodec must be non-null"); + } + + void requireOperationMatches( + DurablePublicationRecord operation, + FilePublishRequest request, + CompiledFileDestination destination, + String requestFingerprint) { + Objects.requireNonNull(operation, "operation must be non-null"); + Objects.requireNonNull(request, "request must be non-null"); + Objects.requireNonNull(destination, "destination must be non-null"); + if (!operation.operationId().equals(request.operationId().value()) + || !operation.requestFingerprint().equals(requestFingerprint) + || !operation.destinationId().equals(destination.destinationId().value()) + || !operation.providerId().equals(destination.providerId()) + || !operation.effectivePolicyRevision().equals(destination.effectivePolicyRevision()) + || !operation.effectivePolicyDigest().equals(destination.effectivePolicyDigest()) + || !operation.routeToken().equals(destination.routeToken()) + || !operation + .routeToken() + .equals(FilePublicationCanonicalDigests.routeToken(operation.effectivePolicyDigest())) + || !operation.publishedFileName().equals(generatedFileName(operation.fileId())) + || !operation + .stageFileName() + .equals(LocalPersistentPayloadOperations.stageFileName(operation.operationId()))) { + throw new IllegalArgumentException("durable operation does not match the current request"); + } + } + + void requireArtifact( + DurablePublicationRecord operation, + LocalPersistentPayloadOperations.VerifiedArtifact artifact) { + Objects.requireNonNull(operation, "operation must be non-null"); + Objects.requireNonNull(artifact, "artifact must be non-null"); + if (artifact.byteSize() != operation.byteSize() + || !artifact.sha256().equals(operation.sha256())) { + throw new IllegalArgumentException("payload artifact does not match the durable operation"); + } + } + + void requireManifest( + DurablePublicationRecord operation, + FilePublishRequest request, + CompiledFileDestination destination, + PrivateFileManifest manifest, + PublishedFileReference reference) { + Objects.requireNonNull(manifest, "manifest must be non-null"); + if (!manifest.operationId().equals(operation.operationId()) + || !manifest.fileId().equals(operation.fileId()) + || !manifest.providerId().equals(operation.providerId()) + || !manifest.fileReference().equals(reference.value()) + || !manifest.requestFingerprint().equals(operation.requestFingerprint()) + || !manifest.destinationId().equals(operation.destinationId()) + || !manifest.schemaId().equals(request.schema().schemaId()) + || manifest.exportSchemaVersion() != request.schema().version() + || !manifest + .schemaDigest() + .equals(FilePublicationCanonicalDigests.schemaDigest(request.schema())) + || !manifest.formatProfileId().equals(request.formatProfileId()) + || !manifest.formatPolicyDigest().equals(destination.formatPolicyDigest()) + || !manifest.effectivePolicyRevision().equals(operation.effectivePolicyRevision()) + || !manifest.effectivePolicyDigest().equals(operation.effectivePolicyDigest()) + || !manifest.publishedFileName().equals(operation.publishedFileName()) + || !manifest.fileVersion().equals(operation.sha256()) + || !manifest.mediaType().equals(MEDIA_TYPE) + || !manifest.charset().equals(CHARSET) + || manifest.byteSize() != operation.byteSize() + || manifest.rowCount() != operation.rowCount() + || manifest.columnCount() != operation.columnCount() + || !manifest.sha256().equals(operation.sha256()) + || manifest.formulaMitigatedCount() != operation.formulaMitigatedCount() + || manifest.publicationGuarantee() != PublicationGuarantee.UNIQUE_ATOMIC_CREATE + || manifest.durabilityGuarantee() != DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC + || !manifest.internalLocator().equals(operation.publishedFileName()) + || !manifest.createdAt().equals(operation.createdAt()) + || manifest.publishedAt().isBefore(operation.sealedAt())) { + throw new IllegalArgumentException("private manifest does not match the durable operation"); + } + } + + void requireReference( + DurablePublicationRecord operation, + PrivateFileManifest manifest, + PublishedReferenceRecord reference, + PublishedFileReference opaqueReference, + String manifestDigest) { + Objects.requireNonNull(reference, "reference must be non-null"); + if (!reference.fileId().equals(operation.fileId()) + || !reference.routeToken().equals(operation.routeToken()) + || !reference.fileReference().equals(opaqueReference.value()) + || !reference.operationId().equals(operation.operationId()) + || !reference.fileVersion().equals(operation.sha256()) + || !reference.manifestDigest().equals(manifestDigest) + || !reference.internalLocator().equals(operation.publishedFileName()) + || !reference.destinationId().equals(operation.destinationId()) + || !reference.providerId().equals(operation.providerId()) + || !reference.publishedFileName().equals(operation.publishedFileName()) + || !reference.mediaType().equals(MEDIA_TYPE) + || !reference.charset().equals(CHARSET) + || reference.byteSize() != operation.byteSize() + || !reference.sha256().equals(operation.sha256()) + || !reference.publishedAt().equals(manifest.publishedAt()) + || !operation.destinationId().equals(reference.destinationId())) { + throw new IllegalArgumentException("reference index does not match the private manifest"); + } + } + + FilePublishReceipt expectedReceipt( + DurablePublicationRecord operation, + FilePublishRequest request, + PrivateFileManifest manifest, + PublishedFileReference reference) { + return new FilePublishReceipt( + new FilePublishOperationId(operation.operationId()), + reference, + request.destinationId(), + operation.publishedFileName(), + new FileVersion(operation.sha256()), + request.formatProfileId(), + MEDIA_TYPE, + CHARSET, + operation.byteSize(), + operation.rowCount(), + operation.columnCount(), + operation.sha256(), + manifest.publishedAt(), + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + operation.formulaMitigatedCount()); + } + + FilePublishReceipt requireTerminalReceipt( + DurablePublicationRecord operation, + FilePublishRequest request, + PrivateFileManifest manifest, + PublishedReferenceRecord reference) { + PublishedFileReference opaqueReference = + referenceCodec.encode(operation.routeToken(), operation.fileId()); + String manifestDigest = canonicalDigest(codec.encodeManifest(manifest)); + requireReference(operation, manifest, reference, opaqueReference, manifestDigest); + String referenceDigest = canonicalDigest(codec.encodeReference(reference)); + if (!operation.manifestDigest().equals(manifestDigest) + || !operation.referenceDigest().equals(referenceDigest)) { + throw new IllegalArgumentException("terminal operation metadata digests do not match"); + } + FilePublishReceipt expected = expectedReceipt(operation, request, manifest, opaqueReference); + FilePublishReceipt stored = codec.decodeReceiptSnapshot(operation.receiptSnapshot()); + if (!expected.equals(stored) || !operation.publishedAt().equals(expected.publishedAt())) { + throw new IllegalArgumentException("terminal receipt snapshot does not match verified truth"); + } + return stored; + } + + String canonicalManifestDigest(PrivateFileManifest manifest) { + return canonicalDigest(codec.encodeManifest(manifest)); + } + + String canonicalReferenceDigest(PublishedReferenceRecord reference) { + return canonicalDigest(codec.encodeReference(reference)); + } + + PublishedFileReference reference(DurablePublicationRecord operation) { + return referenceCodec.encode(operation.routeToken(), operation.fileId()); + } + + static String generatedFileName(String fileId) { + FileserverControlRecordCodec.requireFileId(fileId); + return "file-" + fileId + ".csv"; + } + + private static String canonicalDigest(byte[] bytes) { + try { + return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java new file mode 100644 index 0000000..ba37c64 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java @@ -0,0 +1,770 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Fail-closed POSIX capability attestation for a pre-provisioned persistent root. */ +final class LocalPersistentRootAttestor { + + private static final String CONTROL_DIRECTORY = ".ca-fileserver"; + private static final String DATA_DIRECTORY = "data"; + private static final String[] CONTROL_CHILDREN = { + "staging", "operations", "manifests", "references", "quarantine", "probe" + }; + private static final Set PRIVATE_DIRECTORY_PERMISSIONS = + PosixFilePermissions.fromString("rwx------"); + private static final Set PRIVATE_FILE_PERMISSIONS = + PosixFilePermissions.fromString("rw-------"); + private static final FileAttribute> PRIVATE_DIRECTORY_ATTRIBUTE = + PosixFilePermissions.asFileAttribute(PRIVATE_DIRECTORY_PERMISSIONS); + private static final FileAttribute> PRIVATE_FILE_ATTRIBUTE = + PosixFilePermissions.asFileAttribute(PRIVATE_FILE_PERMISSIONS); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final CapabilityOperations capabilityOperations; + private final FileStoreProbe fileStoreProbe; + private final ParentIdentityProbe parentIdentityProbe; + + LocalPersistentRootAttestor() { + this(systemCapabilityOperations(), systemFileStoreProbe(), systemParentIdentityProbe()); + } + + LocalPersistentRootAttestor( + CapabilityOperations capabilityOperations, FileStoreProbe fileStoreProbe) { + this(capabilityOperations, fileStoreProbe, systemParentIdentityProbe()); + } + + LocalPersistentRootAttestor( + CapabilityOperations capabilityOperations, + FileStoreProbe fileStoreProbe, + ParentIdentityProbe parentIdentityProbe) { + this.capabilityOperations = + Objects.requireNonNull(capabilityOperations, "capabilityOperations must be non-null"); + this.fileStoreProbe = Objects.requireNonNull(fileStoreProbe, "fileStoreProbe must be non-null"); + this.parentIdentityProbe = + Objects.requireNonNull(parentIdentityProbe, "parentIdentityProbe must be non-null"); + } + + LocalPersistentRootEvidence attest(CompiledFileDestination destination) { + Objects.requireNonNull(destination, "destination must be non-null"); + try { + return attestChecked(destination); + } catch (LocalPersistentRootAttestationException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw failure( + "persistent root attestation failed: " + exceptionMessage(exception), exception); + } + } + + void verifyIdentity(LocalPersistentRootEvidence evidence) { + Objects.requireNonNull(evidence, "evidence must be non-null"); + try { + Path root = evidence.root(); + requireAbsoluteNormalized(root); + rejectSymbolicRootOrAncestor(root); + PosixFileAttributes rootAttributes = readPosixRoot(root); + Path realRoot = root.toRealPath(); + if (!realRoot.equals(root)) { + throw failure("root real path no longer matches attested root"); + } + requireIdentity("root", rootAttributes.fileKey(), evidence.rootFileKey()); + PosixFileAttributes confirmedRootAttributes = readPosixRoot(realRoot); + requireIdentity("root", confirmedRootAttributes.fileKey(), evidence.rootFileKey()); + requireEqual( + "root owner", evidence.expectedOwner(), confirmedRootAttributes.owner().getName()); + validateRootPermissions( + confirmedRootAttributes.permissions(), evidence.maximumRootPermissions()); + + FileStoreIdentity rootStore = fileStoreProbe.inspect(root); + requireEqual("root FileStore name", evidence.fileStoreName(), rootStore.name()); + requireEqual("root FileStore type", evidence.fileStoreType(), rootStore.type()); + validateSentinel(root, evidence.mountSentinelName(), evidence.mountSentinelSha256()); + + for (Map.Entry entry : evidence.criticalDirectoryFileKeys().entrySet()) { + Path directory = root.resolve(entry.getKey()); + PosixFileAttributes attributes = readPrivateDirectory(directory, evidence.expectedOwner()); + requireIdentity(entry.getKey(), attributes.fileKey(), entry.getValue()); + FileStoreIdentity directoryStore = fileStoreProbe.inspect(directory); + if (!rootStore.equals(directoryStore)) { + throw failure( + entry.getKey() + " FileStore no longer matches the attested root FileStore"); + } + } + } catch (LocalPersistentRootAttestationException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw failure( + "persistent root identity verification failed: " + exceptionMessage(exception), + exception); + } + } + + private LocalPersistentRootEvidence attestChecked(CompiledFileDestination destination) + throws IOException { + Path configuredRoot = destination.rootDirectory(); + requireAbsoluteNormalized(configuredRoot); + rejectSymbolicRootOrAncestor(configuredRoot); + PosixFileAttributes rootAttributes = readPosixRoot(configuredRoot); + Path realRoot = configuredRoot.toRealPath(); + if (!realRoot.equals(configuredRoot)) { + throw failure("root real path must exactly match the configured absolute path"); + } + PosixFileAttributes confirmedRootAttributes = readPosixRoot(realRoot); + requireIdentity( + "root", + confirmedRootAttributes.fileKey(), + requireFileKey("root", rootAttributes.fileKey())); + rootAttributes = confirmedRootAttributes; + String rootFileKey = requireFileKey("root", rootAttributes.fileKey()); + requireEqual("root owner", destination.expectedOwner(), rootAttributes.owner().getName()); + validateRootPermissions(rootAttributes.permissions(), destination.maximumRootPermissions()); + + FileStoreIdentity rootStore = fileStoreProbe.inspect(realRoot); + requireEqual("root FileStore name", destination.expectedFileStoreName(), rootStore.name()); + requireEqual("root FileStore type", destination.expectedFileStoreType(), rootStore.type()); + String sentinelDigest = + validateSentinel( + realRoot, destination.mountSentinelName(), destination.mountSentinelSha256()); + + Map directoryFileKeys = + prepareInternalDirectories(realRoot, destination.expectedOwner(), rootStore, rootFileKey); + requireSecureDirectoryStream(realRoot); + runCapabilityProbe(realRoot.resolve(CONTROL_DIRECTORY).resolve("probe")); + + LocalPersistentRootEvidence evidence = + new LocalPersistentRootEvidence( + realRoot, + rootFileKey, + rootStore.name(), + rootStore.type(), + destination.expectedOwner(), + destination.maximumRootPermissions(), + destination.mountSentinelName(), + sentinelDigest, + true, + true, + true, + directoryFileKeys); + verifyIdentity(evidence); + return evidence; + } + + private Map prepareInternalDirectories( + Path root, String expectedOwner, FileStoreIdentity rootStore, String rootFileKey) + throws IOException { + Map identities = new LinkedHashMap<>(); + List createdDirectories = new ArrayList<>(); + try { + prevalidateExistingHierarchy(root, expectedOwner, rootStore); + + Path data = root.resolve(DATA_DIRECTORY); + PosixFileAttributes dataAttributes = + createOrValidatePrivateDirectory( + DATA_DIRECTORY, + data, + root, + expectedOwner, + rootFileKey, + rootStore, + createdDirectories); + identities.put(DATA_DIRECTORY, requireFileKey(DATA_DIRECTORY, dataAttributes.fileKey())); + + Path control = root.resolve(CONTROL_DIRECTORY); + PosixFileAttributes controlAttributes = + createOrValidatePrivateDirectory( + CONTROL_DIRECTORY, + control, + root, + expectedOwner, + rootFileKey, + rootStore, + createdDirectories); + String controlFileKey = requireFileKey(CONTROL_DIRECTORY, controlAttributes.fileKey()); + identities.put(CONTROL_DIRECTORY, controlFileKey); + + for (String child : CONTROL_CHILDREN) { + Path directory = control.resolve(child); + String relativeName = CONTROL_DIRECTORY + "/" + child; + PosixFileAttributes attributes = + createOrValidatePrivateDirectory( + relativeName, + directory, + control, + expectedOwner, + controlFileKey, + rootStore, + createdDirectories); + identities.put(relativeName, requireFileKey(relativeName, attributes.fileKey())); + } + } catch (IOException | RuntimeException exception) { + rollbackCreatedDirectories(createdDirectories, exception); + throw exception; + } + return Map.copyOf(identities); + } + + private void prevalidateExistingHierarchy( + Path root, String expectedOwner, FileStoreIdentity rootStore) throws IOException { + requireSameFileStore("root parent", root, rootStore); + prevalidatePrivateDirectoryIfPresent( + DATA_DIRECTORY, root.resolve(DATA_DIRECTORY), expectedOwner, rootStore); + + Path control = root.resolve(CONTROL_DIRECTORY); + PosixFileAttributes controlAttributes = + prevalidatePrivateDirectoryIfPresent(CONTROL_DIRECTORY, control, expectedOwner, rootStore); + if (controlAttributes == null) { + return; + } + requireSameFileStore("control parent", control, rootStore); + for (String child : CONTROL_CHILDREN) { + String relativeName = CONTROL_DIRECTORY + "/" + child; + prevalidatePrivateDirectoryIfPresent( + relativeName, control.resolve(child), expectedOwner, rootStore); + } + } + + private PosixFileAttributes prevalidatePrivateDirectoryIfPresent( + String relativeName, Path directory, String expectedOwner, FileStoreIdentity rootStore) + throws IOException { + if (!existsNoFollow(directory)) { + return null; + } + PosixFileAttributes attributes = readPrivateDirectory(directory, expectedOwner); + requireSameFileStore(relativeName, directory, rootStore); + return attributes; + } + + private PosixFileAttributes createOrValidatePrivateDirectory( + String relativeName, + Path directory, + Path parent, + String expectedOwner, + String expectedParentFileKey, + FileStoreIdentity rootStore, + List createdDirectories) + throws IOException { + requireSameFileStore(relativeName + " parent", parent, rootStore); + requireParentIdentity(parent, expectedParentFileKey, "before " + relativeName); + boolean created = false; + try { + Files.createDirectory(directory, PRIVATE_DIRECTORY_ATTRIBUTE); + created = true; + String createdFileKey = + readNoFollowDirectoryFileKey(directory, "new internal directory " + relativeName); + createdDirectories.add( + new CreatedDirectory(directory, parent, createdFileKey, expectedParentFileKey)); + Files.setPosixFilePermissions(directory, PRIVATE_DIRECTORY_PERMISSIONS); + } catch (java.nio.file.FileAlreadyExistsException ignored) { + // Existing state is accepted only after the same strict no-follow validation below. + } + + PosixFileAttributes attributes = readPrivateDirectory(directory, expectedOwner); + requireSameFileStore(relativeName, directory, rootStore); + requireParentIdentity(parent, expectedParentFileKey, "after " + relativeName + " validation"); + if (created) { + capabilityOperations.forceDirectory(parent); + requireParentIdentity( + parent, expectedParentFileKey, "after " + relativeName + " parent force"); + } + return attributes; + } + + private void rollbackCreatedDirectories( + List createdDirectories, Exception primaryFailure) { + Exception rollbackFailure = null; + for (int index = createdDirectories.size() - 1; index >= 0; index--) { + CreatedDirectory created = createdDirectories.get(index); + try { + rollbackCreatedDirectory(created); + } catch (IOException | RuntimeException exception) { + if (rollbackFailure == null) { + rollbackFailure = exception; + } else { + rollbackFailure.addSuppressed(exception); + } + } + } + if (rollbackFailure != null) { + primaryFailure.addSuppressed(rollbackFailure); + } + } + + private void rollbackCreatedDirectory(CreatedDirectory created) throws IOException { + String parentFileKey = parentIdentityProbe.inspect(created.parent()); + if (!created.parentFileKey().equals(parentFileKey)) { + throw failure("refusing rollback because parent identity changed: " + created.parent()); + } + + BasicFileAttributes attributes; + try { + attributes = + Files.readAttributes( + created.directory(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException ignored) { + return; + } + if (attributes.isSymbolicLink() || !attributes.isDirectory()) { + throw failure("refusing rollback of replaced internal directory: " + created.directory()); + } + String actualFileKey = + requireFileKey("rollback directory " + created.directory(), attributes.fileKey()); + if (!created.directoryFileKey().equals(actualFileKey)) { + throw failure( + "refusing rollback because internal directory identity changed: " + created.directory()); + } + Files.delete(created.directory()); + capabilityOperations.forceDirectory(created.parent()); + } + + private static String readNoFollowDirectoryFileKey(Path directory, String description) + throws IOException { + BasicFileAttributes attributes = + Files.readAttributes(directory, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink() || !attributes.isDirectory()) { + throw failure(description + " must be a no-follow directory"); + } + return requireFileKey(description, attributes.fileKey()); + } + + private void requireParentIdentity(Path parent, String expectedFileKey, String phase) + throws IOException { + String actualFileKey = parentIdentityProbe.inspect(parent); + if (!expectedFileKey.equals(actualFileKey)) { + throw failure("parent identity mismatch " + phase + ": " + parent); + } + } + + private static boolean existsNoFollow(Path path) throws IOException { + try { + Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + return true; + } catch (NoSuchFileException exception) { + return false; + } + } + + private static PosixFileAttributes readPrivateDirectory(Path directory, String expectedOwner) + throws IOException { + PosixFileAttributes attributes; + try { + attributes = + Files.readAttributes(directory, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException exception) { + throw failure("internal directory does not exist: " + directory, exception); + } + if (attributes.isSymbolicLink()) { + throw failure("internal directory is a symbolic link: " + directory); + } + if (!attributes.isDirectory()) { + throw failure("internal path is not a directory: " + directory); + } + Path realDirectory = directory.toRealPath(); + if (!realDirectory.equals(directory)) { + throw failure("internal directory real path mismatch: " + directory); + } + PosixFileAttributes confirmedAttributes = + Files.readAttributes(directory, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + requireIdentity( + "internal directory " + directory, + confirmedAttributes.fileKey(), + requireFileKey("internal directory " + directory, attributes.fileKey())); + if (confirmedAttributes.isSymbolicLink() || !confirmedAttributes.isDirectory()) { + throw failure("internal directory identity changed during validation: " + directory); + } + attributes = confirmedAttributes; + if (expectedOwner != null && !expectedOwner.equals(attributes.owner().getName())) { + throw failure("internal directory owner mismatch: " + directory); + } + if (!PRIVATE_DIRECTORY_PERMISSIONS.equals(attributes.permissions())) { + throw failure("internal directory must have exact private mode 0700: " + directory); + } + return attributes; + } + + private void requireSameFileStore( + String relativeName, Path directory, FileStoreIdentity rootStore) throws IOException { + FileStoreIdentity directoryStore = fileStoreProbe.inspect(directory); + if (!rootStore.equals(directoryStore)) { + throw failure(relativeName + " FileStore does not match root FileStore"); + } + } + + private void requireSecureDirectoryStream(Path root) throws IOException { + try (SecureDirectoryStream ignored = capabilityOperations.openSecureDirectory(root)) { + // Opening and closing without exposing the stream is the capability evidence. + } + } + + private void runCapabilityProbe(Path probeDirectory) throws IOException { + String token = randomToken(); + Path file = probeDirectory.resolve("probe-" + token + ".tmp"); + Path link = probeDirectory.resolve("probe-" + token + ".link"); + try { + Set options = + Set.of( + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + try (FileChannel channel = FileChannel.open(file, options, PRIVATE_FILE_ATTRIBUTE)) { + Files.setPosixFilePermissions(file, PRIVATE_FILE_PERMISSIONS); + writeFully( + channel, ByteBuffer.wrap(token.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + capabilityOperations.forceFile(channel); + } + capabilityOperations.createHardLink(link, file); + capabilityOperations.forceDirectory(probeDirectory); + requireRegularProbeArtifact(file, "probe file"); + requireRegularProbeArtifact(link, "probe hard-link"); + requireSameFileKey(file, link); + } catch (IOException | RuntimeException exception) { + cleanupProbe(probeDirectory, link, file, exception); + throw exception; + } + cleanupProbe(probeDirectory, link, file, null); + } + + private void cleanupProbe(Path probeDirectory, Path link, Path file, Exception primaryFailure) + throws IOException { + Exception cleanupFailure = null; + try { + Files.deleteIfExists(link); + } catch (IOException | RuntimeException exception) { + cleanupFailure = exception; + } + try { + Files.deleteIfExists(file); + } catch (IOException | RuntimeException exception) { + cleanupFailure = combineCleanupFailures(cleanupFailure, exception); + } + try { + capabilityOperations.forceDirectory(probeDirectory); + } catch (IOException | RuntimeException exception) { + cleanupFailure = combineCleanupFailures(cleanupFailure, exception); + } + if (cleanupFailure == null) { + return; + } + if (primaryFailure != null) { + primaryFailure.addSuppressed(cleanupFailure); + return; + } + if (cleanupFailure instanceof IOException ioException) { + throw ioException; + } + throw (RuntimeException) cleanupFailure; + } + + private static Exception combineCleanupFailures(Exception existing, Exception additional) { + if (existing == null) { + return additional; + } + existing.addSuppressed(additional); + return existing; + } + + private static void requireRegularProbeArtifact(Path artifact, String description) + throws IOException { + BasicFileAttributes attributes = + Files.readAttributes(artifact, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink() || !attributes.isRegularFile()) { + throw failure(description + " is not a regular no-follow file"); + } + } + + private static void requireSameFileKey(Path file, Path link) throws IOException { + BasicFileAttributes fileAttributes = + Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + BasicFileAttributes linkAttributes = + Files.readAttributes(link, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + String fileKey = requireFileKey("probe file", fileAttributes.fileKey()); + String linkKey = requireFileKey("probe hard-link", linkAttributes.fileKey()); + if (!fileKey.equals(linkKey)) { + throw failure("exclusive hard-link probe did not preserve file identity"); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer content) throws IOException { + while (content.hasRemaining()) { + channel.write(content); + } + } + + private static String validateSentinel(Path root, String sentinelName, String expectedDigest) + throws IOException { + Path sentinel = root.resolve(sentinelName); + BasicFileAttributes before; + try { + before = Files.readAttributes(sentinel, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException exception) { + throw failure("mount sentinel does not exist: " + sentinel, exception); + } + if (before.isSymbolicLink()) { + throw failure("mount sentinel must not be a symbolic link: " + sentinel); + } + if (!before.isRegularFile()) { + throw failure("mount sentinel must be a regular no-follow file: " + sentinel); + } + + String digest = digestNoFollow(sentinel); + BasicFileAttributes after = + Files.readAttributes(sentinel, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + String beforeFileKey = requireFileKey("mount sentinel", before.fileKey()); + requireIdentity("mount sentinel", after.fileKey(), beforeFileKey); + if (!expectedDigest.equals(digest)) { + throw failure("mount sentinel SHA-256 mismatch"); + } + return digest; + } + + private static String digestNoFollow(Path file) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 must be available", exception); + } + Set options = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + try (FileChannel channel = FileChannel.open(file, options)) { + ByteBuffer buffer = ByteBuffer.allocate(8192); + while (channel.read(buffer) >= 0) { + buffer.flip(); + digest.update(buffer); + buffer.clear(); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static void rejectSymbolicRootOrAncestor(Path root) throws IOException { + Path current = root.getRoot(); + if (current == null) { + throw failure("root path must be absolute"); + } + for (Path element : root) { + current = current.resolve(element); + BasicFileAttributes attributes; + try { + attributes = + Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException exception) { + throw failure("root or an existing ancestor does not exist: " + current, exception); + } + if (attributes.isSymbolicLink()) { + String kind = current.equals(root) ? "root" : "root ancestor"; + throw failure(kind + " is a symbolic link: " + current); + } + } + } + + private static PosixFileAttributes readPosixRoot(Path root) throws IOException { + PosixFileAttributes attributes; + try { + attributes = Files.readAttributes(root, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException exception) { + throw failure("root does not exist: " + root, exception); + } catch (UnsupportedOperationException exception) { + throw failure("strict POSIX root attributes are required", exception); + } + if (attributes.isSymbolicLink()) { + throw failure("root is a symbolic link: " + root); + } + if (!attributes.isDirectory()) { + throw failure("root must be a directory: " + root); + } + return attributes; + } + + private static void validateRootPermissions( + Set actual, Set maximum) { + if (actual.contains(PosixFilePermission.GROUP_WRITE) + || actual.contains(PosixFilePermission.OTHERS_WRITE)) { + throw failure("root must never be group/world writable"); + } + if (!maximum.containsAll(actual)) { + throw failure("root permissions are broader than configured maximum-root-mode"); + } + } + + private static void requireAbsoluteNormalized(Path root) { + if (!root.isAbsolute()) { + throw failure("root path must be absolute"); + } + if (!root.equals(root.normalize())) { + throw failure("root path must already be normalized"); + } + } + + private static void requireEqual(String description, String expected, String actual) { + if (!expected.equals(actual)) { + throw failure(description + " mismatch: expected " + expected + " but was " + actual); + } + } + + private static void requireIdentity( + String description, Object actualFileKey, String expectedFileKey) { + String actual = requireFileKey(description, actualFileKey); + if (!expectedFileKey.equals(actual)) { + throw failure(description + " identity mismatch"); + } + } + + private static String requireFileKey(String description, Object fileKey) { + if (fileKey == null) { + throw failure(description + " file identity is unavailable"); + } + return fileKey.toString(); + } + + private static String randomToken() { + byte[] bytes = new byte[16]; + SECURE_RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private static String exceptionMessage(Exception exception) { + return exception.getMessage() == null + ? exception.getClass().getSimpleName() + : exception.getMessage(); + } + + private static LocalPersistentRootAttestationException failure(String message) { + return new LocalPersistentRootAttestationException(message); + } + + private static LocalPersistentRootAttestationException failure(String message, Throwable cause) { + return new LocalPersistentRootAttestationException(message, cause); + } + + static CapabilityOperations systemCapabilityOperations() { + return SystemCapabilityOperations.INSTANCE; + } + + static FileStoreProbe systemFileStoreProbe() { + return path -> { + FileStore store = Files.getFileStore(path); + return new FileStoreIdentity(store.name(), store.type(), store.toString()); + }; + } + + static ParentIdentityProbe systemParentIdentityProbe() { + return parent -> { + PosixFileAttributes attributes = + Files.readAttributes(parent, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink() || !attributes.isDirectory()) { + throw failure("parent must be a no-follow directory: " + parent); + } + Path realParent = parent.toRealPath(); + if (!realParent.equals(parent)) { + throw failure("parent real path mismatch: " + parent); + } + String fileKey = requireFileKey("parent " + parent, attributes.fileKey()); + PosixFileAttributes confirmedAttributes = + Files.readAttributes(parent, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + requireIdentity("parent " + parent, confirmedAttributes.fileKey(), fileKey); + if (confirmedAttributes.isSymbolicLink() || !confirmedAttributes.isDirectory()) { + throw failure("parent identity changed during validation: " + parent); + } + return fileKey; + }; + } + + interface CapabilityOperations { + + SecureDirectoryStream openSecureDirectory(Path directory) throws IOException; + + void forceFile(FileChannel channel) throws IOException; + + void createHardLink(Path link, Path existing) throws IOException; + + void forceDirectory(Path directory) throws IOException; + } + + interface FileStoreProbe { + + FileStoreIdentity inspect(Path path) throws IOException; + } + + interface ParentIdentityProbe { + + String inspect(Path parent) throws IOException; + } + + record FileStoreIdentity(String name, String type, String identity) { + + FileStoreIdentity { + Objects.requireNonNull(name, "name must be non-null"); + Objects.requireNonNull(type, "type must be non-null"); + Objects.requireNonNull(identity, "identity must be non-null"); + } + } + + static final class LocalPersistentRootAttestationException extends IllegalStateException { + + private LocalPersistentRootAttestationException(String message) { + super(message); + } + + private LocalPersistentRootAttestationException(String message, Throwable cause) { + super(message, cause); + } + } + + private record CreatedDirectory( + Path directory, Path parent, String directoryFileKey, String parentFileKey) {} + + private enum SystemCapabilityOperations implements CapabilityOperations { + INSTANCE; + + @Override + @SuppressWarnings({"StreamResourceLeak", "unchecked"}) + public SecureDirectoryStream openSecureDirectory(Path directory) throws IOException { + DirectoryStream stream = Files.newDirectoryStream(directory); + if (stream instanceof SecureDirectoryStream secureStream) { + return (SecureDirectoryStream) secureStream; + } + stream.close(); + throw new IOException("SecureDirectoryStream unavailable for " + directory); + } + + @Override + public void forceFile(FileChannel channel) throws IOException { + channel.force(true); + } + + @Override + public void createHardLink(Path link, Path existing) throws IOException { + Files.createLink(link, existing); + } + + @Override + public void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java new file mode 100644 index 0000000..45347a6 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootEvidence.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Immutable startup evidence for one pre-provisioned local persistent root. */ +record LocalPersistentRootEvidence( + Path root, + String rootFileKey, + String fileStoreName, + String fileStoreType, + String expectedOwner, + Set maximumRootPermissions, + String mountSentinelName, + String mountSentinelSha256, + boolean secureDirectoryStream, + boolean directorySync, + boolean exclusiveHardLink, + Map criticalDirectoryFileKeys) { + + LocalPersistentRootEvidence { + root = Path.of(Objects.requireNonNull(root, "root must be non-null").toString()); + Objects.requireNonNull(rootFileKey, "rootFileKey must be non-null"); + Objects.requireNonNull(fileStoreName, "fileStoreName must be non-null"); + Objects.requireNonNull(fileStoreType, "fileStoreType must be non-null"); + Objects.requireNonNull(expectedOwner, "expectedOwner must be non-null"); + maximumRootPermissions = + Set.copyOf( + Objects.requireNonNull( + maximumRootPermissions, "maximumRootPermissions must be non-null")); + Objects.requireNonNull(mountSentinelName, "mountSentinelName must be non-null"); + Objects.requireNonNull(mountSentinelSha256, "mountSentinelSha256 must be non-null"); + criticalDirectoryFileKeys = + Map.copyOf( + Objects.requireNonNull( + criticalDirectoryFileKeys, "criticalDirectoryFileKeys must be non-null")); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java index 9ce87d5..a88c895 100644 --- a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalCodec.java @@ -1,6 +1,10 @@ package dev.caskeleton.adapter.outbound.fileserver; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -71,6 +75,23 @@ final class LocalPublicationJournalCodec { } } + static LocalPublicationJournalRecord decodeCanonical(byte[] bytes) { + try { + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)); + LocalPublicationJournalRecord record = decode(bytes); + if (!Arrays.equals(bytes, encode(record))) { + throw new IllegalArgumentException("journal is not canonical"); + } + return record; + } catch (CharacterCodingException | IllegalArgumentException exception) { + throw new LocalPublicationJournalException("local publication journal is corrupt", exception); + } + } + private static int integer(Map values, String key) { return Integer.parseInt(values.get(key)); } diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java new file mode 100644 index 0000000..25527a7 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.time.Instant; +import java.util.Objects; + +/** Provider-private schema-v1 manifest for exact artifact verification and restoration. */ +record PrivateFileManifest( + int schemaVersion, + String operationId, + String fileId, + String providerId, + String fileReference, + String requestFingerprint, + String destinationId, + String schemaId, + int exportSchemaVersion, + String schemaDigest, + String formatProfileId, + String formatPolicyDigest, + String effectivePolicyRevision, + String effectivePolicyDigest, + String publishedFileName, + String fileVersion, + String mediaType, + String charset, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + PublicationGuarantee publicationGuarantee, + DurabilityGuarantee durabilityGuarantee, + String internalLocator, + Instant createdAt, + Instant publishedAt) { + + static final int CURRENT_SCHEMA_VERSION = 1; + + PrivateFileManifest { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("unsupported private manifest schema"); + } + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + FileserverControlRecordCodec.requireFileId(fileId); + FileserverControlRecordCodec.requireLogicalId(providerId, "providerId"); + FileserverControlRecordCodec.requireReferenceMatches(fileReference, fileId, null); + FileserverControlRecordCodec.requireDigest(requestFingerprint, "requestFingerprint"); + FileserverControlRecordCodec.requireLogicalId(destinationId, "destinationId"); + FileserverControlRecordCodec.requireText(schemaId, "schemaId", 128); + if (exportSchemaVersion < 1) { + throw new IllegalArgumentException("exportSchemaVersion must be positive"); + } + FileserverControlRecordCodec.requireDigest(schemaDigest, "schemaDigest"); + FileserverControlRecordCodec.requireText(formatProfileId, "formatProfileId", 128); + FileserverControlRecordCodec.requireDigest(formatPolicyDigest, "formatPolicyDigest"); + FileserverControlRecordCodec.requireText( + effectivePolicyRevision, "effectivePolicyRevision", 128); + FileserverControlRecordCodec.requireDigest(effectivePolicyDigest, "effectivePolicyDigest"); + FileserverControlRecordCodec.requireSegment(publishedFileName, "publishedFileName"); + FileserverControlRecordCodec.requireText(fileVersion, "fileVersion", 128); + FileserverControlRecordCodec.requireText(mediaType, "mediaType", 128); + FileserverControlRecordCodec.requireText(charset, "charset", 64); + if (byteSize < 0 || rowCount < 0 || columnCount < 1 || formulaMitigatedCount < 0) { + throw new IllegalArgumentException("manifest sizes and counts are out of range"); + } + FileserverControlRecordCodec.requireFormulaCountWithinCells( + rowCount, columnCount, formulaMitigatedCount, "manifest"); + FileserverControlRecordCodec.requireDigest(sha256, "sha256"); + Objects.requireNonNull(publicationGuarantee, "publicationGuarantee must be non-null"); + Objects.requireNonNull(durabilityGuarantee, "durabilityGuarantee must be non-null"); + FileserverControlRecordCodec.requireSegment(internalLocator, "internalLocator"); + FileserverControlRecordCodec.requireInstant(createdAt, "createdAt"); + FileserverControlRecordCodec.requireOrderedInstant(createdAt, publishedAt, "publishedAt"); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java new file mode 100644 index 0000000..6061c91 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import java.time.Instant; + +/** Schema-v1 direct reference index from an opaque file ID to provider-private metadata. */ +record PublishedReferenceRecord( + int schemaVersion, + String fileId, + String routeToken, + String fileReference, + String operationId, + String fileVersion, + String manifestDigest, + String internalLocator, + String destinationId, + String providerId, + String publishedFileName, + String mediaType, + String charset, + long byteSize, + String sha256, + Instant publishedAt) { + + static final int CURRENT_SCHEMA_VERSION = 1; + + PublishedReferenceRecord { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("unsupported published reference record schema"); + } + FileserverControlRecordCodec.requireFileId(fileId); + FileserverControlRecordCodec.requireRouteToken(routeToken); + FileserverControlRecordCodec.requireReferenceMatches(fileReference, fileId, routeToken); + FileserverControlRecordCodec.requireText(operationId, "operationId", 128); + FileserverControlRecordCodec.requireText(fileVersion, "fileVersion", 128); + FileserverControlRecordCodec.requireDigest(manifestDigest, "manifestDigest"); + FileserverControlRecordCodec.requireSegment(internalLocator, "internalLocator"); + FileserverControlRecordCodec.requireLogicalId(destinationId, "destinationId"); + FileserverControlRecordCodec.requireLogicalId(providerId, "providerId"); + FileserverControlRecordCodec.requireSegment(publishedFileName, "publishedFileName"); + FileserverControlRecordCodec.requireText(mediaType, "mediaType", 128); + FileserverControlRecordCodec.requireText(charset, "charset", 64); + if (byteSize < 0) { + throw new IllegalArgumentException("reference byteSize must be non-negative"); + } + FileserverControlRecordCodec.requireDigest(sha256, "sha256"); + FileserverControlRecordCodec.requireInstant(publishedAt, "publishedAt"); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java new file mode 100644 index 0000000..a722a59 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/R2PublishedReferenceCodec.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.PublishedFileReference; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Set; + +/** Strict codec for the provider-neutral R2 opaque published-file reference. */ +final class R2PublishedReferenceCodec { + + private static final String VERSION = "fsr1"; + private static final String ROUTE_PATTERN = "[a-z][a-z0-9]{5,31}"; + private static final String FILE_ID_PATTERN = "[0-9a-f]{32}"; + private static final String CHECK_PATTERN = "[0-9a-f]{12}"; + + PublishedFileReference encode(String routeToken, String fileId) { + requireRouteToken(routeToken); + requireFileId(fileId); + String prefix = VERSION + "." + routeToken + "." + fileId; + return new PublishedFileReference(prefix + "." + checkDigits(prefix)); + } + + DecodedReference decode(PublishedFileReference reference, Set allowedRouteTokens) { + Objects.requireNonNull(reference, "reference must be non-null"); + Objects.requireNonNull(allowedRouteTokens, "allowedRouteTokens must be non-null"); + if (allowedRouteTokens.isEmpty()) { + throw new IllegalArgumentException("route allowlist must not be empty"); + } + allowedRouteTokens.forEach(R2PublishedReferenceCodec::requireRouteToken); + + String[] segments = reference.value().split("\\.", -1); + if (segments.length != 4 + || !VERSION.equals(segments[0]) + || !segments[1].matches(ROUTE_PATTERN) + || !segments[2].matches(FILE_ID_PATTERN) + || !segments[3].matches(CHECK_PATTERN)) { + throw new IllegalArgumentException("published file reference is malformed"); + } + if (!allowedRouteTokens.contains(segments[1])) { + throw new IllegalArgumentException("published file reference route is not allowed"); + } + + String prefix = segments[0] + "." + segments[1] + "." + segments[2]; + byte[] expected = checkDigits(prefix).getBytes(StandardCharsets.US_ASCII); + byte[] supplied = segments[3].getBytes(StandardCharsets.US_ASCII); + if (!MessageDigest.isEqual(expected, supplied)) { + throw new IllegalArgumentException("published file reference check digits do not match"); + } + return new DecodedReference(segments[1], segments[2]); + } + + private static void requireRouteToken(String routeToken) { + if (routeToken == null || !routeToken.matches(ROUTE_PATTERN)) { + throw new IllegalArgumentException("routeToken must match " + ROUTE_PATTERN); + } + } + + private static void requireFileId(String fileId) { + if (fileId == null || !fileId.matches(FILE_ID_PATTERN)) { + throw new IllegalArgumentException( + "fileId must be exactly 32 lowercase hexadecimal characters"); + } + } + + private static String checkDigits(String prefix) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(prefix.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest, 0, 6); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + record DecodedReference(String routeToken, String fileId) {} +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java new file mode 100644 index 0000000..4cb3696 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublicationPort; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.TabularRowProducer; +import java.util.Map; +import java.util.Objects; + +/** Exact destination router with no default or fallback provider. */ +final class RoutingFilePublicationAdapter implements FilePublicationPort { + + private final Map routes; + + RoutingFilePublicationAdapter(Map routes) { + this.routes = Map.copyOf(Objects.requireNonNull(routes, "routes must be non-null")); + if (this.routes.isEmpty()) { + throw new IllegalArgumentException("fileserver routing requires at least one destination"); + } + } + + @Override + public FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer) { + if (request == null) { + throw invalidRequest("file publication request must be non-null"); + } + if (producer == null) { + throw invalidRequest("file publication producer must be non-null"); + } + FilePublicationProvider provider = routes.get(request.destinationId()); + if (provider == null) { + throw invalidRequest("file publication destination is not configured"); + } + return provider.publish(request, producer); + } + + private static FilePublicationException invalidRequest(String message) { + return new FilePublicationException(FilePublicationException.Reason.INVALID_REQUEST, message); + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java index 0acedb8..106f143 100644 --- a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java @@ -40,7 +40,7 @@ class FilePublicationConfigTest { assertThat(context).doesNotHaveBean(FileExportPort.class); assertThat(context).hasSingleBean(FilePublicationPort.class); - FileExportProperties properties = context.getBean(FileExportProperties.class); + FileExportSettings properties = context.getBean(FileExportSettings.class); assertThat(properties.getDestinationId()).isEqualTo("nightly-export"); assertThat(properties.getMaximumRows()).isEqualTo(125); assertThat(properties.getMaximumEncodedBytes()).isEqualTo(4096); @@ -59,7 +59,7 @@ class FilePublicationConfigTest { context -> { assertThat(context).hasSingleBean(FileExportPort.class); assertThat(context).hasSingleBean(FilePublicationPort.class); - FileExportProperties properties = context.getBean(FileExportProperties.class); + FileExportSettings properties = context.getBean(FileExportSettings.class); assertThat(properties.getLegacyBaseDirectory()).isEqualTo("build/test-files-legacy"); }); } diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java new file mode 100644 index 0000000..c5ad051 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompilerTest.java @@ -0,0 +1,969 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.CellType; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class FileserverBindingCompilerTest { + + private static final String SENTINEL_SHA256 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @TempDir Path tempDirectory; + + @Test + void disabledEmptySettingsCompileToAnImmutableEmptyMapWithoutCreatingDirectories() { + Path undeclaredRoot = tempDirectory.resolve("must-not-be-created"); + + Map result = + FileserverBindingCompiler.compile(new FileserverR2Settings(false, null, null)); + + assertThat(result).isEmpty(); + assertThatThrownBy( + () -> + result.put( + new FileDestinationId("unexpected"), + compiledDestinationFor(undeclaredRoot.toAbsolutePath()))) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(undeclaredRoot).doesNotExist(); + } + + @Test + void settingsDefensivelyCopyMapsAndConvertNullMapsToEmptyMaps() { + Map destinations = new LinkedHashMap<>(); + Map providers = new LinkedHashMap<>(); + destinations.put("local-export", destination("local-primary")); + providers.put("local-primary", provider(tempDirectory.resolve("root").toAbsolutePath())); + + FileserverR2Settings settings = new FileserverR2Settings(true, destinations, providers); + destinations.clear(); + providers.clear(); + + assertThat(settings.destinations()).containsOnlyKeys("local-export"); + assertThat(settings.providers()).containsOnlyKeys("local-primary"); + assertThat(new FileserverR2Settings(false, null, null).destinations()).isEmpty(); + assertThat(new FileserverR2Settings(false, null, null).providers()).isEmpty(); + assertThatThrownBy(() -> settings.destinations().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void springBootBinderBindsNestedDestinationAndProviderMapsBeforeCompilation() { + Path root = tempDirectory.resolve("bound-root").toAbsolutePath(); + Map properties = new LinkedHashMap<>(); + properties.put("app.fileserver.enabled", "true"); + properties.put("app.fileserver.destinations.local-export.provider-ref", "local-primary"); + properties.put( + "app.fileserver.destinations.local-export.required-publication", "unique-atomic-create"); + properties.put( + "app.fileserver.destinations.local-export.required-durability", "file-and-directory-sync"); + properties.put("app.fileserver.destinations.local-export.maximum-rows", "125"); + properties.put("app.fileserver.destinations.local-export.maximum-encoded-bytes", "4096"); + properties.put("app.fileserver.providers.local-primary.type", "local-persistent"); + properties.put("app.fileserver.providers.local-primary.root-directory", root.toString()); + properties.put("app.fileserver.providers.local-primary.auto-create", "false"); + properties.put("app.fileserver.providers.local-primary.strict-path-security", "true"); + properties.put( + "app.fileserver.providers.local-primary.expected-file-store-name", "expected-store"); + properties.put( + "app.fileserver.providers.local-primary.expected-file-store-type", "expected-type"); + properties.put( + "app.fileserver.providers.local-primary.mount-sentinel-name", ".ca-fileserver-volume"); + properties.put("app.fileserver.providers.local-primary.mount-sentinel-sha256", SENTINEL_SHA256); + properties.put("app.fileserver.providers.local-primary.expected-owner", "fileserver"); + properties.put("app.fileserver.providers.local-primary.maximum-root-mode", "0750"); + + FileserverR2Settings settings = + new Binder(new MapConfigurationPropertySource(properties)) + .bind("app.fileserver", Bindable.of(FileserverR2Settings.class)) + .orElseThrow(() -> new AssertionError("app.fileserver settings were not bound")); + Map compiled = + FileserverBindingCompiler.compile(settings); + + assertThat(compiled).containsOnlyKeys(new FileDestinationId("local-export")); + assertThat(compiled.get(new FileDestinationId("local-export"))) + .satisfies( + destination -> { + assertThat(destination.providerId()).isEqualTo("local-primary"); + assertThat(destination.rootDirectory()).isEqualTo(root); + assertThat(destination.maximumRows()).isEqualTo(125); + assertThat(destination.maximumEncodedBytes()).isEqualTo(4096); + }); + } + + @Test + void enabledSettingsRequireAnExplicitDestination() { + FileserverR2Settings settings = + new FileserverR2Settings( + true, + Map.of(), + Map.of("local-primary", provider(tempDirectory.resolve("root").toAbsolutePath()))); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settings)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destination"); + } + + @Test + void enabledSettingsRequireAnExplicitProvider() { + FileserverR2Settings settings = + new FileserverR2Settings( + true, Map.of("local-export", destination("local-primary")), Map.of()); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settings)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider"); + } + + @Test + void rejectsUnknownProviderReference() { + FileserverR2Settings settings = + enabled( + Map.of("local-export", destination("missing")), + Map.of("local-primary", provider(tempDirectory.resolve("root").toAbsolutePath()))); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settings)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing"); + } + + @Test + void rejectsEveryProviderTypeExceptExactLocalPersistent() { + for (String type : + new String[] {"shared-mounted", "sftp", "LOCAL-PERSISTENT", "local-persistent "}) { + FileserverR2Settings settings = + enabled( + Map.of("local-export", destination("local-primary")), + Map.of( + "local-primary", provider(tempDirectory.resolve("root").toAbsolutePath(), type))); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settings)) + .as("provider type %s", type) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("local-persistent"); + } + } + + @Test + void compilesOnlyTheDeclaredExactBinding() { + Path root = tempDirectory.resolve("root").toAbsolutePath().normalize(); + + Map result = + FileserverBindingCompiler.compile(validSettings(root)); + + FileDestinationId destinationId = new FileDestinationId("local-export"); + assertThat(result).containsOnlyKeys(destinationId); + assertThat(result.get(destinationId)) + .satisfies( + destination -> { + assertThat(destination.destinationId()).isEqualTo(destinationId); + assertThat(destination.providerId()).isEqualTo("local-primary"); + assertThat(destination.rootDirectory()).isEqualTo(root); + assertThat(destination.maximumRows()).isEqualTo(1_000_000); + assertThat(destination.maximumEncodedBytes()).isEqualTo(1_073_741_824); + assertThat(destination.expectedFileStoreName()).isEqualTo("expected-store"); + assertThat(destination.expectedFileStoreType()).isEqualTo("expected-type"); + assertThat(destination.mountSentinelName()).isEqualTo(".ca-fileserver-volume"); + assertThat(destination.mountSentinelSha256()).isEqualTo(SENTINEL_SHA256); + assertThat(destination.expectedOwner()).isEqualTo("fileserver"); + assertThat(destination.maximumRootMode()).isEqualTo("0750"); + assertThat(destination.maximumRootPermissions()) + .containsExactlyInAnyOrder( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE); + assertThat(destination.requiredPublicationGuarantee()) + .isEqualTo(PublicationGuarantee.UNIQUE_ATOMIC_CREATE); + assertThat(destination.requiredDurabilityGuarantee()) + .isEqualTo(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + }); + } + + @Test + void trimsIdentifiersButRejectsBlankDestinationProviderAndProviderReferenceIds() { + assertThat( + FileserverBindingCompiler.compile( + validSettingsWithIds(" local-export ", " local-primary "))) + .containsOnlyKeys(new FileDestinationId("local-export")); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + enabled( + Map.of(" ", destination("local-primary")), + Map.of( + "local-primary", + provider(tempDirectory.resolve("root").toAbsolutePath()))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destination"); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + enabled( + Map.of("local-export", destination("local-primary")), + Map.of(" ", provider(tempDirectory.resolve("root").toAbsolutePath()))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider"); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + enabled( + Map.of("local-export", destination(" ")), + Map.of( + "local-primary", + provider(tempDirectory.resolve("root").toAbsolutePath()))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider-ref"); + } + + @Test + void rejectsDuplicateDestinationAndProviderIdsAfterTrimming() { + Map destinations = new LinkedHashMap<>(); + destinations.put("local-export", destination("local-primary")); + destinations.put(" local-export ", destination("local-primary")); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + enabled( + destinations, + Map.of( + "local-primary", + provider(tempDirectory.resolve("root").toAbsolutePath()))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate") + .hasMessageContaining("local-export"); + + Map providers = new LinkedHashMap<>(); + providers.put("local-primary", provider(tempDirectory.resolve("root-one").toAbsolutePath())); + providers.put(" local-primary ", provider(tempDirectory.resolve("root-two").toAbsolutePath())); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + enabled(Map.of("local-export", destination("local-primary")), providers))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate") + .hasMessageContaining("local-primary"); + } + + @Test + void rejectsRelativeRootDirectory() { + FileserverR2Settings settings = + enabled( + Map.of("local-export", destination("local-primary")), + Map.of("local-primary", provider(Path.of("relative/root")))); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settings)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("absolute"); + } + + @Test + void rejectsAbsoluteRootDirectoryThatIsNotAlreadyNormalized() { + Path nonNormalizedRoot = + tempDirectory.resolve("configured-parent").resolve("..").resolve("actual-root"); + assertThat(nonNormalizedRoot).isAbsolute(); + assertThat(nonNormalizedRoot).isNotEqualTo(nonNormalizedRoot.normalize()); + + FileserverR2Settings settings = + enabled( + Map.of("local-export", destination("local-primary")), + Map.of("local-primary", provider(nonNormalizedRoot))); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settings)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("normalized"); + assertThatThrownBy(() -> compiledDestinationFor(nonNormalizedRoot)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("normalized"); + } + + @Test + void rejectsAutoCreateAndDisabledStrictPathSecurity() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + settingsWithProvider(providerBuilder(root).autoCreate(true).build()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("auto-create"); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + settingsWithProvider(providerBuilder(root).strictPathSecurity(false).build()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("strict-path-security"); + } + + @Test + void rejectsUnsupportedPublicationAndDurabilityRequirements() { + FileserverR2Settings.DestinationSettings normal = destination("local-primary"); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + settingsWithDestination( + new FileserverR2Settings.DestinationSettings( + normal.providerRef(), + "atomic-create", + normal.requiredDurability(), + normal.maximumRows(), + normal.maximumEncodedBytes())))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unique-atomic-create"); + + assertThatThrownBy( + () -> + FileserverBindingCompiler.compile( + settingsWithDestination( + new FileserverR2Settings.DestinationSettings( + normal.providerRef(), + normal.requiredPublication(), + "process-local-sync", + normal.maximumRows(), + normal.maximumEncodedBytes())))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("file-and-directory-sync"); + } + + @Test + void rejectsBlankRootAttestationInputs() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + + for (int blankField = 0; blankField < 4; blankField++) { + FileserverR2Settings.ProviderSettings provider = + switch (blankField) { + case 0 -> providerBuilder(root).rootDirectory(" ").build(); + case 1 -> providerBuilder(root).expectedFileStoreName(" ").build(); + case 2 -> providerBuilder(root).expectedFileStoreType(" ").build(); + case 3 -> providerBuilder(root).expectedOwner(" ").build(); + default -> throw new AssertionError("unexpected attestation field index"); + }; + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settingsWithProvider(provider))) + .as("blank attestation field %s", blankField) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blank"); + } + } + + @Test + void rejectsInvalidMountSentinelNameAndDigest() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + + for (String invalidName : + new String[] { + "", + " ", + ".", + "..", + "../sentinel", + "dir/sentinel", + "sentinel\nname", + "sentinel\u0000name", + "sentinel\uD800", + "a".repeat(256), + "가".repeat(86) + }) { + FileserverR2Settings.ProviderSettings provider = + providerBuilder(root).mountSentinelName(invalidName).build(); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settingsWithProvider(provider))) + .as("sentinel name %s", invalidName) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("sentinel"); + } + + FileserverR2Settings.ProviderSettings maximumLengthSentinel = + providerBuilder(root).mountSentinelName("a".repeat(255)).build(); + assertThat(FileserverBindingCompiler.compile(settingsWithProvider(maximumLengthSentinel))) + .containsOnlyKeys(new FileDestinationId("local-export")); + + for (String invalidDigest : + new String[] { + "", + " ", + "abc", + SENTINEL_SHA256.substring(1), + SENTINEL_SHA256.toUpperCase(Locale.ROOT), + SENTINEL_SHA256 + "0" + }) { + FileserverR2Settings.ProviderSettings provider = + providerBuilder(root).mountSentinelSha256(invalidDigest).build(); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settingsWithProvider(provider))) + .as("sentinel digest %s", invalidDigest) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SHA-256"); + } + } + + @Test + void rejectsInvalidOrWritableByOthersMaximumRootMode() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + + for (String invalidMode : + new String[] {"", " ", "750", "0088", "0780", "07500", "0752", "0770"}) { + FileserverR2Settings.ProviderSettings provider = + providerBuilder(root).maximumRootMode(invalidMode).build(); + + assertThatThrownBy(() -> FileserverBindingCompiler.compile(settingsWithProvider(provider))) + .as("maximum root mode %s", invalidMode) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximum-root-mode"); + } + } + + @Test + void rejectsNonpositiveRowAndByteBounds() { + FileserverR2Settings.DestinationSettings normal = destination("local-primary"); + + for (long maximumRows : new long[] {0, -1}) { + FileserverR2Settings.DestinationSettings destination = + new FileserverR2Settings.DestinationSettings( + normal.providerRef(), + normal.requiredPublication(), + normal.requiredDurability(), + maximumRows, + normal.maximumEncodedBytes()); + + assertThatThrownBy( + () -> FileserverBindingCompiler.compile(settingsWithDestination(destination))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximum-rows"); + } + + for (long maximumEncodedBytes : new long[] {0, -1}) { + FileserverR2Settings.DestinationSettings destination = + new FileserverR2Settings.DestinationSettings( + normal.providerRef(), + normal.requiredPublication(), + normal.requiredDurability(), + normal.maximumRows(), + maximumEncodedBytes); + + assertThatThrownBy( + () -> FileserverBindingCompiler.compile(settingsWithDestination(destination))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximum-encoded-bytes"); + } + } + + @Test + void canonicalDigestsLengthPrefixValuesAndDoNotDependOnMapIterationOrder() { + assertThat(FilePublicationCanonicalDigests.digestOrderedValues(List.of("ab", "c"))) + .isNotEqualTo(FilePublicationCanonicalDigests.digestOrderedValues(List.of("a", "bc"))); + + Map firstOrder = new LinkedHashMap<>(); + firstOrder.put("destinationId", "local-export"); + firstOrder.put("providerId", "local-primary"); + Map reverseOrder = new LinkedHashMap<>(); + reverseOrder.put("providerId", "local-primary"); + reverseOrder.put("destinationId", "local-export"); + + assertThat(FilePublicationCanonicalDigests.digestNamedFields(firstOrder)) + .isEqualTo(FilePublicationCanonicalDigests.digestNamedFields(reverseOrder)); + } + + @Test + void freezesRestartStableEffectiveAndFormatPolicyIdentity() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + + CompiledFileDestination first = + FileserverBindingCompiler.compile(validSettings(root)) + .get(new FileDestinationId("local-export")); + CompiledFileDestination restarted = + FileserverBindingCompiler.compile(validSettings(root)) + .get(new FileDestinationId("local-export")); + + assertThat(first.effectivePolicyRevision()).isEqualTo("fileserver-effective-policy-v1"); + assertThat(first.effectivePolicyDigest()) + .isEqualTo("ea26b7bda7d1c83c34da62f14f920964d9d87193be0604e8415102d77e210fac"); + assertThat(first.routeToken()).isEqualTo("rea26b7bda7d1c83c34da62f14f92096"); + assertThat(first.routeToken()).hasSize(32).matches("r[0-9a-f]{31}"); + assertThat(first.formatPolicyDigest()) + .isEqualTo("cb59c3249946e7366d2e52529cc42eb8b3283b5d52fbf841edefc4701602afc7"); + assertThat(restarted.effectivePolicyRevision()).isEqualTo(first.effectivePolicyRevision()); + assertThat(restarted.effectivePolicyDigest()).isEqualTo(first.effectivePolicyDigest()); + assertThat(restarted.routeToken()).isEqualTo(first.routeToken()); + assertThat(restarted.formatPolicyDigest()).isEqualTo(first.formatPolicyDigest()); + } + + @Test + void effectiveIdentityDoesNotDependOnDestinationOrProviderMapIterationOrder() { + Path firstRoot = tempDirectory.resolve("root-one").toAbsolutePath(); + Path secondRoot = tempDirectory.resolve("root-two").toAbsolutePath(); + Map destinations = new LinkedHashMap<>(); + destinations.put("second-export", destination("second-provider")); + destinations.put("first-export", destination("first-provider")); + Map providers = new LinkedHashMap<>(); + providers.put("first-provider", provider(firstRoot)); + providers.put("second-provider", provider(secondRoot)); + + Map first = + FileserverBindingCompiler.compile(enabled(destinations, providers)); + + Map reversedDestinations = + new LinkedHashMap<>(); + reversedDestinations.put("first-export", destination("first-provider")); + reversedDestinations.put("second-export", destination("second-provider")); + Map reversedProviders = new LinkedHashMap<>(); + reversedProviders.put("second-provider", provider(secondRoot)); + reversedProviders.put("first-provider", provider(firstRoot)); + Map restarted = + FileserverBindingCompiler.compile(enabled(reversedDestinations, reversedProviders)); + + assertThat(restarted).containsOnlyKeys(first.keySet().toArray(FileDestinationId[]::new)); + first.forEach( + (id, destination) -> { + assertThat(restarted.get(id).effectivePolicyDigest()) + .isEqualTo(destination.effectivePolicyDigest()); + assertThat(restarted.get(id).routeToken()).isEqualTo(destination.routeToken()); + }); + } + + @Test + void rejectsCompiledAllowlistRouteTokenCollisionInsteadOfExtendingTheToken() { + String sharedRoutePrefix = "a".repeat(31); + String firstDigest = sharedRoutePrefix + "0".repeat(33); + String secondDigest = sharedRoutePrefix + "f".repeat(33); + assertThat(firstDigest).isNotEqualTo(secondDigest); + assertThat(FilePublicationCanonicalDigests.routeToken(firstDigest)) + .isEqualTo(FilePublicationCanonicalDigests.routeToken(secondDigest)); + Map policyDigests = new LinkedHashMap<>(); + policyDigests.put(new FileDestinationId("first-export"), firstDigest); + policyDigests.put(new FileDestinationId("second-export"), secondDigest); + + assertThatThrownBy(() -> FileserverBindingCompiler.deriveUniqueRouteTokens(policyDigests)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("route token") + .hasMessageContaining("collision") + .hasMessageContaining("raaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + @Test + void schemaDigestCoversSchemaIdentityVersionAndEveryOrderedColumnField() { + ExportSchema baseline = + schema( + "work-log", + 3, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32), + new Column("summary", CellType.TEXT, true, FormulaPolicy.MITIGATE, 512))); + String digest = FilePublicationCanonicalDigests.schemaDigest(baseline); + + assertThat(digest) + .isEqualTo("2d64afed0358055c34785db1610ee74586f1973f3948b88b6fc8b53181b83c16"); + assertThat(FilePublicationCanonicalDigests.schemaDigest(baseline)).isEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema("work-log-v2", 3, baseline.columns()))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest(schema("work-log", 4, baseline.columns()))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", 3, List.of(baseline.columns().get(1), baseline.columns().get(0))))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", + 3, + List.of( + new Column("identifier", CellType.INTEGER, false, FormulaPolicy.REJECT, 32), + baseline.columns().get(1))))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", + 3, + List.of( + new Column("id", CellType.TEXT, false, FormulaPolicy.REJECT, 32), + baseline.columns().get(1))))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", + 3, + List.of( + new Column("id", CellType.INTEGER, true, FormulaPolicy.REJECT, 32), + baseline.columns().get(1))))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", + 3, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 64), + baseline.columns().get(1))))) + .isNotEqualTo(digest); + assertThat( + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", + 3, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.ALLOW, 32), + baseline.columns().get(1))))) + .isNotEqualTo(digest); + } + + @Test + void formatPolicyRevisionAndCanonicalOptionsAreStable() { + assertThat(FilePublicationCanonicalDigests.FORMAT_ENCODER_REVISION) + .isEqualTo("csv-rfc4180-encoder-v1"); + assertThat(FilePublicationCanonicalDigests.formatPolicyDigest()) + .isEqualTo("cb59c3249946e7366d2e52529cc42eb8b3283b5d52fbf841edefc4701602afc7") + .isEqualTo(FilePublicationCanonicalDigests.formatPolicyDigest()); + } + + @Test + void compiledIdentityRejectsDirectRevisionAndFormatDigestSubstitution() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + CompiledFileDestination canonical = compiledDestinationFor(root); + + assertThatThrownBy( + () -> + compiledDestinationWithIdentity( + root, + "fileserver-effective-policy-v2", + canonical.effectivePolicyDigest(), + canonical.routeToken(), + canonical.formatPolicyDigest())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("effectivePolicyRevision"); + assertThatThrownBy( + () -> + compiledDestinationWithIdentity( + root, + canonical.effectivePolicyRevision(), + canonical.effectivePolicyDigest(), + canonical.routeToken(), + "f".repeat(64))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("formatPolicyDigest"); + } + + @Test + void compiledIdentityRejectsDigestThatDoesNotDescribeItsActualPolicyFields() { + Path root = tempDirectory.resolve("root").toAbsolutePath(); + CompiledFileDestination canonical = compiledDestinationFor(root); + String unrelatedDigest = "1".repeat(64); + + assertThat(unrelatedDigest).isNotEqualTo(canonical.effectivePolicyDigest()); + assertThatThrownBy( + () -> + compiledDestinationWithIdentity( + root, + canonical.effectivePolicyRevision(), + unrelatedDigest, + FilePublicationCanonicalDigests.routeToken(unrelatedDigest), + canonical.formatPolicyDigest())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("effectivePolicyDigest") + .hasMessageContaining("canonical"); + } + + @Test + void schemaDigestRejectsMalformedUtf16WithoutReplacementCharacterAliases() { + for (String malformed : List.of("\uD800", "\uDC00")) { + ExportSchema malformedSchemaId = + schema( + "work-log-" + malformed, + 1, + List.of(new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32))); + ExportSchema malformedColumnName = + schema( + "work-log", + 1, + List.of( + new Column( + "column-" + malformed, CellType.INTEGER, false, FormulaPolicy.REJECT, 32))); + String replacementSchemaDigest = + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log-?", + 1, + List.of(new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32)))); + String replacementColumnDigest = + FilePublicationCanonicalDigests.schemaDigest( + schema( + "work-log", + 1, + List.of( + new Column("column-?", CellType.INTEGER, false, FormulaPolicy.REJECT, 32)))); + + assertThat(replacementSchemaDigest).matches("[0-9a-f]{64}"); + assertThat(replacementColumnDigest).matches("[0-9a-f]{64}"); + assertStrictUtf8Rejection(malformedSchemaId, malformed); + assertStrictUtf8Rejection(malformedColumnName, malformed); + } + } + + @Test + void schemaDigestAcceptsSupplementaryUnicodeWithoutLosingRestartStability() { + ExportSchema unicodeSchema = + schema( + "작업-\uD83D\uDE80", + 7, + List.of( + new Column("메모-\uD83E\uDDEA", CellType.TEXT, true, FormulaPolicy.MITIGATE, 1_024))); + + assertThat(FilePublicationCanonicalDigests.schemaDigest(unicodeSchema)) + .isEqualTo("6c6568f87eadcd12aaf5efcff7dfd081115a82233b73903f8f0a029287b76eac") + .isEqualTo(FilePublicationCanonicalDigests.schemaDigest(unicodeSchema)); + } + + @Test + @ResourceLock("java.util.Locale.default") + void schemaDigestDoesNotDependOnTheJvmDefaultLocale() { + ExportSchema schema = + schema( + "work-log", + 3, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32), + new Column("summary", CellType.TEXT, true, FormulaPolicy.MITIGATE, 512))); + Locale original = Locale.getDefault(); + String baseline = FilePublicationCanonicalDigests.schemaDigest(schema); + try { + Locale.setDefault(Locale.forLanguageTag("ar-EG")); + assertThat(FilePublicationCanonicalDigests.schemaDigest(schema)).isEqualTo(baseline); + } finally { + Locale.setDefault(original); + } + } + + private static ExportSchema schema(String schemaId, int version, List columns) { + return new ExportSchema(schemaId, version, columns); + } + + private static void assertStrictUtf8Rejection(ExportSchema schema, String malformed) { + assertThatThrownBy(() -> FilePublicationCanonicalDigests.schemaDigest(schema)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("canonical value must be valid UTF-8") + .satisfies( + failure -> + assertThat(failure.getCause()) + .isInstanceOf(java.nio.charset.CharacterCodingException.class) + .hasMessageNotContaining(malformed)); + } + + private FileserverR2Settings validSettings(Path root) { + return enabled( + Map.of("local-export", destination("local-primary")), + Map.of("local-primary", provider(root))); + } + + private FileserverR2Settings validSettingsWithIds(String destinationId, String providerId) { + return enabled( + Map.of(destinationId, destination(providerId)), + Map.of(providerId, provider(tempDirectory.resolve("root").toAbsolutePath()))); + } + + private FileserverR2Settings settingsWithDestination( + FileserverR2Settings.DestinationSettings destination) { + return enabled( + Map.of("local-export", destination), + Map.of("local-primary", provider(tempDirectory.resolve("root").toAbsolutePath()))); + } + + private FileserverR2Settings settingsWithProvider( + FileserverR2Settings.ProviderSettings provider) { + return enabled( + Map.of("local-export", destination("local-primary")), Map.of("local-primary", provider)); + } + + private static FileserverR2Settings enabled( + Map destinations, + Map providers) { + return new FileserverR2Settings(true, destinations, providers); + } + + private static FileserverR2Settings.DestinationSettings destination(String providerRef) { + return new FileserverR2Settings.DestinationSettings( + providerRef, "unique-atomic-create", "file-and-directory-sync", 1_000_000, 1_073_741_824); + } + + private static FileserverR2Settings.ProviderSettings provider(Path root) { + return providerBuilder(root).build(); + } + + private static FileserverR2Settings.ProviderSettings provider(Path root, String type) { + return providerBuilder(root).type(type).build(); + } + + private static ProviderSettingsBuilder providerBuilder(Path root) { + return new ProviderSettingsBuilder(root); + } + + private static CompiledFileDestination compiledDestinationFor(Path root) { + return new CompiledFileDestination( + new FileDestinationId("unexpected"), + "local-primary", + root, + 1, + 1, + "expected-store", + "expected-type", + ".ca-fileserver-volume", + SENTINEL_SHA256, + "fileserver", + "0750", + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE), + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + } + + private static CompiledFileDestination compiledDestinationWithIdentity( + Path root, + String effectivePolicyRevision, + String effectivePolicyDigest, + String routeToken, + String formatPolicyDigest) { + return new CompiledFileDestination( + new FileDestinationId("unexpected"), + "local-primary", + root, + 1, + 1, + "expected-store", + "expected-type", + ".ca-fileserver-volume", + SENTINEL_SHA256, + "fileserver", + "0750", + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE), + effectivePolicyRevision, + effectivePolicyDigest, + routeToken, + formatPolicyDigest, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + } + + private static final class ProviderSettingsBuilder { + + private String type = "local-persistent"; + private String rootDirectory; + private boolean autoCreate; + private boolean strictPathSecurity = true; + private String expectedFileStoreName = "expected-store"; + private String expectedFileStoreType = "expected-type"; + private String mountSentinelName = ".ca-fileserver-volume"; + private String mountSentinelSha256 = SENTINEL_SHA256; + private String expectedOwner = "fileserver"; + private String maximumRootMode = "0750"; + + private ProviderSettingsBuilder(Path root) { + rootDirectory = root.toString(); + } + + private ProviderSettingsBuilder type(String value) { + type = value; + return this; + } + + private ProviderSettingsBuilder rootDirectory(String value) { + rootDirectory = value; + return this; + } + + private ProviderSettingsBuilder autoCreate(boolean value) { + autoCreate = value; + return this; + } + + private ProviderSettingsBuilder strictPathSecurity(boolean value) { + strictPathSecurity = value; + return this; + } + + private ProviderSettingsBuilder expectedFileStoreName(String value) { + expectedFileStoreName = value; + return this; + } + + private ProviderSettingsBuilder expectedFileStoreType(String value) { + expectedFileStoreType = value; + return this; + } + + private ProviderSettingsBuilder mountSentinelName(String value) { + mountSentinelName = value; + return this; + } + + private ProviderSettingsBuilder mountSentinelSha256(String value) { + mountSentinelSha256 = value; + return this; + } + + private ProviderSettingsBuilder expectedOwner(String value) { + expectedOwner = value; + return this; + } + + private ProviderSettingsBuilder maximumRootMode(String value) { + maximumRootMode = value; + return this; + } + + private FileserverR2Settings.ProviderSettings build() { + return new FileserverR2Settings.ProviderSettings( + type, + rootDirectory, + autoCreate, + strictPathSecurity, + expectedFileStoreName, + expectedFileStoreType, + mountSentinelName, + mountSentinelSha256, + expectedOwner, + maximumRootMode); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java new file mode 100644 index 0000000..8ad022d --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodecTest.java @@ -0,0 +1,924 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FileVersion; +import dev.caskeleton.application.filepublication.PublishedFileReference; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Locale; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class FileserverControlRecordCodecTest { + + private static final String FILE_ID = "00112233445566778899aabbccddeeff"; + private static final String DIGEST_A = "a".repeat(64); + private static final String DIGEST_B = "b".repeat(64); + private static final String DIGEST_C = "c".repeat(64); + private static final Instant CREATED_AT = Instant.parse("2026-07-28T01:02:03Z"); + private static final Instant SEALED_AT = Instant.parse("2026-07-28T01:02:04Z"); + private static final Instant PUBLISHED_AT = Instant.parse("2026-07-28T01:02:05Z"); + + private final R2PublishedReferenceCodec referenceCodec = new R2PublishedReferenceCodec(); + private final FileserverControlRecordCodec codec = new FileserverControlRecordCodec(); + + @Test + void referenceRoundTripRejectsCorruptionUnknownRouteAndTruncation() { + PublishedFileReference reference = referenceCodec.encode("routea1", FILE_ID); + + assertThat(reference.value()) + .isEqualTo("fsr1.routea1.00112233445566778899aabbccddeeff.201c97ae3a2c") + .matches("fsr1\\.routea1\\.[0-9a-f]{32}\\.[0-9a-f]{12}"); + assertThat(referenceCodec.decode(reference, Set.of("routea1")).fileId()).isEqualTo(FILE_ID); + assertThat(referenceCodec.decode(reference, Set.of("routea1")).routeToken()) + .isEqualTo("routea1"); + + assertThatThrownBy( + () -> + referenceCodec.decode( + new PublishedFileReference( + reference.value().substring(0, reference.value().length() - 1) + "0"), + Set.of("routea1"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> referenceCodec.decode(reference, Set.of("routeb2"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + referenceCodec.decode( + new PublishedFileReference( + reference.value().substring(0, reference.value().length() - 1)), + Set.of("routea1"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void referenceRejectsUppercaseWrongSegmentsAndNonCanonicalTokens() { + String canonical = referenceCodec.encode("routea1", FILE_ID).value(); + + for (String invalid : + new String[] { + canonical.toUpperCase(Locale.ROOT), + canonical + ".extra", + canonical.replace("routea1", "route-a"), + canonical.replace(FILE_ID, FILE_ID.substring(1)), + "fsr2" + canonical.substring(4), + canonical.replace("routea1", "Routea1") + }) { + assertThatThrownBy( + () -> referenceCodec.decode(new PublishedFileReference(invalid), Set.of("routea1"))) + .isInstanceOf(IllegalArgumentException.class); + } + + assertThatThrownBy(() -> referenceCodec.encode("route-a", FILE_ID)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> referenceCodec.encode("routea1", FILE_ID.toUpperCase(Locale.ROOT))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> referenceCodec.decode(referenceCodec.encode("routea1", FILE_ID), Set.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void operationRecordAndTerminalReceiptHaveCanonicalExactRoundTrips() { + FilePublishReceipt receipt = receipt(); + String snapshot = codec.encodeReceiptSnapshot(receipt); + DurablePublicationRecord record = publishedRecord(snapshot); + + byte[] encoded = codec.encodeOperation(record); + + assertThat(encoded).hasSizeLessThanOrEqualTo(16_384); + assertThat(codec.decodeOperation(encoded)).isEqualTo(record); + assertThat(codec.decodeReceiptSnapshot(snapshot)).isEqualTo(receipt); + assertThat(snapshot).startsWith("rsv1.").doesNotContain("="); + assertThat(new String(encoded, StandardCharsets.UTF_8)) + .startsWith( + "{\"schemaVersion\":2,\"stateRevision\":6,\"state\":\"PUBLISHED\"," + + "\"operationId\":\"operation-01\"") + .endsWith("\"receiptSnapshot\":\"" + snapshot + "\"}"); + assertThat(fieldCount(encoded)).isEqualTo(25); + } + + @Test + void manifestAndReferenceIndexHaveCanonicalExactRoundTrips() { + PrivateFileManifest manifest = manifest(); + PublishedReferenceRecord reference = referenceRecord(); + + byte[] encodedManifest = codec.encodeManifest(manifest); + byte[] encodedReference = codec.encodeReference(reference); + + assertThat(encodedManifest).hasSizeLessThanOrEqualTo(16_384); + assertThat(encodedReference).hasSizeLessThanOrEqualTo(16_384); + assertThat(codec.decodeManifest(encodedManifest)).isEqualTo(manifest); + assertThat(codec.decodeReference(encodedReference)).isEqualTo(reference); + assertThat(new String(encodedManifest, StandardCharsets.UTF_8)) + .startsWith("{\"schemaVersion\":1,\"operationId\":\"operation-01\""); + assertThat(new String(encodedReference, StandardCharsets.UTF_8)) + .startsWith("{\"schemaVersion\":1,\"fileId\":\"" + FILE_ID + "\""); + } + + @Test + void allRecordDecodersRejectNewerMissingUnknownAndDuplicateFields() { + assertClosedSchema( + codec.encodeOperation(publishedRecord(codec.encodeReceiptSnapshot(receipt()))), + codec::decodeOperation, + "\"schemaVersion\":2", + "\"schemaVersion\":3", + "\"stateRevision\":6"); + assertClosedSchema( + codec.encodeManifest(manifest()), + codec::decodeManifest, + "\"schemaVersion\":1", + "\"schemaVersion\":2", + "\"operationId\":\"operation-01\""); + assertClosedSchema( + codec.encodeReference(referenceRecord()), + codec::decodeReference, + "\"schemaVersion\":1", + "\"schemaVersion\":2", + "\"fileId\":\"" + FILE_ID + "\""); + } + + @Test + void canonicalDecoderRejectsWhitespaceReorderingEscapesNumbersUtf8AndTrailingContent() { + byte[] canonical = + codec.encodeOperation(publishedRecord(codec.encodeReceiptSnapshot(receipt()))); + String json = new String(canonical, StandardCharsets.UTF_8); + + for (String invalid : + new String[] { + " " + json, + json.replace( + "\"schemaVersion\":2,\"stateRevision\":6", "\"stateRevision\":6,\"schemaVersion\":2"), + json.replace("\"operation-01\"", "\"operation\\u002d01\""), + json.replace("\"stateRevision\":6", "\"stateRevision\":06"), + json.replace("\"stateRevision\":6", "\"stateRevision\":+6"), + json.replace("\"stateRevision\":6", "\"stateRevision\":-0"), + json.replace("\"stateRevision\":6", "\"stateRevision\":9223372036854775808"), + json + "null", + json.replace("\"operation-01\"", "\"operation-\\ud800\"") + }) { + assertThatThrownBy(() -> codec.decodeOperation(invalid.getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy( + () -> codec.decodeOperation(new byte[] {'{', '"', (byte) 0xc3, (byte) 0x28, '"', '}'})) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void operationStateRevisionAndPresenceInvariantsFailClosed() { + assertThatThrownBy( + () -> + operation( + 1, + DurablePublicationRecord.State.SEALED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + "", + "", + null, + "", + "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + operation( + 1, + DurablePublicationRecord.State.WRITING, + 42, + 0, + 0, + "", + null, + "", + "", + null, + "", + "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + operation( + 6, + DurablePublicationRecord.State.PUBLISHED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + DIGEST_A, + DIGEST_B, + PUBLISHED_AT, + "", + "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + operation( + 7, + DurablePublicationRecord.State.QUARANTINED, + 0, + 0, + 0, + "", + null, + "", + "", + null, + "", + "")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void quarantinedNullShaFailsClosedWithIllegalArgumentException() { + assertThatThrownBy( + () -> + operationWithFormulaCount( + 2, + DurablePublicationRecord.State.QUARANTINED, + 0, + 0, + 0, + null, + 0, + null, + "", + "", + null, + "INTEGRITY_FAILURE", + "")) + .isExactlyInstanceOf(IllegalArgumentException.class); + } + + @Test + void formulaMitigationCountCannotExceedCellsAndUsesOverflowSafeBounds() { + assertThatThrownBy( + () -> + operationWithFormulaCount( + 2, + DurablePublicationRecord.State.SEALED, + 42, + 1, + 2, + DIGEST_C, + 3, + SEALED_AT, + "", + "", + null, + "", + "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> manifestWithCounts(1, 2, 3)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> codec.encodeReceiptSnapshot(receiptWithCounts(1, 2, 3))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + operationWithFormulaCount( + 2, + DurablePublicationRecord.State.SEALED, + 42, + 0, + 2, + DIGEST_C, + 1, + SEALED_AT, + "", + "", + null, + "", + "")) + .isInstanceOf(IllegalArgumentException.class); + + long overflowRows = Long.MAX_VALUE / 2 + 1; + DurablePublicationRecord overflowSafeOperation = + operationWithFormulaCount( + 2, + DurablePublicationRecord.State.SEALED, + 42, + overflowRows, + 2, + DIGEST_C, + Long.MAX_VALUE, + SEALED_AT, + "", + "", + null, + "", + ""); + PrivateFileManifest overflowSafeManifest = manifestWithCounts(overflowRows, 2, Long.MAX_VALUE); + FilePublishReceipt overflowSafeReceipt = receiptWithCounts(overflowRows, 2, Long.MAX_VALUE); + + assertThat(codec.decodeOperation(codec.encodeOperation(overflowSafeOperation))) + .isEqualTo(overflowSafeOperation); + assertThat(codec.decodeManifest(codec.encodeManifest(overflowSafeManifest))) + .isEqualTo(overflowSafeManifest); + assertThat(codec.decodeReceiptSnapshot(codec.encodeReceiptSnapshot(overflowSafeReceipt))) + .isEqualTo(overflowSafeReceipt); + } + + @Test + void allSevenStatesRoundTripOnlyWithTheirRequiredProgressionFields() { + DurablePublicationRecord writing = + operation( + 1, DurablePublicationRecord.State.WRITING, 0, 0, 0, "", null, "", "", null, "", ""); + DurablePublicationRecord sealed = + operation( + 2, + DurablePublicationRecord.State.SEALED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + "", + "", + null, + "", + ""); + DurablePublicationRecord dataPublished = + operation( + 3, + DurablePublicationRecord.State.DATA_PUBLISHED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + "", + "", + null, + "", + ""); + DurablePublicationRecord manifestPublished = + operation( + 4, + DurablePublicationRecord.State.MANIFEST_PUBLISHED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + DIGEST_A, + "", + null, + "", + ""); + DurablePublicationRecord referencePublished = + operation( + 5, + DurablePublicationRecord.State.REFERENCE_PUBLISHED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + DIGEST_A, + DIGEST_B, + null, + "", + ""); + DurablePublicationRecord published = publishedRecord(codec.encodeReceiptSnapshot(receipt())); + DurablePublicationRecord quarantined = + operation( + 2, + DurablePublicationRecord.State.QUARANTINED, + 0, + 0, + 0, + "", + null, + "", + "", + null, + "INTEGRITY_FAILURE", + ""); + + assertThat( + Set.of( + writing.state(), + sealed.state(), + dataPublished.state(), + manifestPublished.state(), + referencePublished.state(), + published.state(), + quarantined.state())) + .containsExactlyInAnyOrder(DurablePublicationRecord.State.values()); + for (DurablePublicationRecord record : + new DurablePublicationRecord[] { + writing, + sealed, + dataPublished, + manifestPublished, + referencePublished, + published, + quarantined + }) { + assertThat(codec.decodeOperation(codec.encodeOperation(record))).isEqualTo(record); + if (record.state() != DurablePublicationRecord.State.PUBLISHED) { + assertThat(record.receiptSnapshot()).isEmpty(); + } + } + } + + @Test + void recordsRejectUnsafeLocatorsDigestsEnumsAndSensitiveOrRawContentFields() { + for (String locator : + new String[] { + "/srv/report.csv", + "C:\\data\\report.csv", + "https:report.csv", + ".", + "..", + "report\u0001.csv" + }) { + assertThatThrownBy(() -> withManifestLocator(locator)) + .isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy(() -> withManifestSchemaDigest(DIGEST_A.toUpperCase(Locale.ROOT))) + .isInstanceOf(IllegalArgumentException.class); + + String manifestJson = new String(codec.encodeManifest(manifest()), StandardCharsets.UTF_8); + for (String field : new String[] {"credential", "rawRows", "rawCell", "absolutePath"}) { + assertThatThrownBy( + () -> + codec.decodeManifest( + manifestJson + .substring(0, manifestJson.length() - 1) + .concat(",\"" + field + "\":\"secret\"}") + .getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy( + () -> + codec.decodeManifest( + manifestJson + .replace( + "\"durabilityGuarantee\":\"FILE_AND_DIRECTORY_SYNC\"", + "\"durabilityGuarantee\":\"LOCAL_DISK\"") + .getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void receiptSnapshotRejectsPaddingWrongVersionNonCanonicalPayloadAndUnsafeData() { + String snapshot = codec.encodeReceiptSnapshot(receipt()); + + assertThatThrownBy(() -> codec.decodeReceiptSnapshot(snapshot + "=")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> codec.decodeReceiptSnapshot("rsv2." + snapshot.substring(5))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> codec.encodeReceiptSnapshot(receiptWithPublishedFileName("../report.csv"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void canonicalCodecRoundTripsSupplementaryUnicodeInOpaqueText() { + FilePublishReceipt unicodeReceipt = + new FilePublishReceipt( + new FilePublishOperationId("operation-\uD83D\uDE00"), + referenceCodec.encode("routea1", FILE_ID), + new FileDestinationId("local-export"), + "report.csv", + new FileVersion("version-\uD83D\uDE80"), + "csv-\uD83D\uDCC4", + "text/csv", + "UTF-8", + 42, + 1, + 2, + DIGEST_C, + PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + 0); + + String snapshot = codec.encodeReceiptSnapshot(unicodeReceipt); + + assertThat(codec.decodeReceiptSnapshot(snapshot)).isEqualTo(unicodeReceipt); + } + + @Test + void receiptSnapshotRejectsBase64urlAliasWithNonZeroTrailingBits() { + String canonical = receiptSnapshotWithUnusedTrailingBits(); + String canonicalPayload = canonical.substring("rsv1.".length()); + String alias = nonCanonicalTrailingBitAlias(canonical); + String aliasPayload = alias.substring("rsv1.".length()); + + assertThat(canonicalPayload.length() % 4).isIn(2, 3); + assertThat(Base64.getUrlDecoder().decode(aliasPayload)) + .isEqualTo(Base64.getUrlDecoder().decode(canonicalPayload)); + assertThat(alias).isNotEqualTo(canonical); + assertThatThrownBy(() -> codec.decodeReceiptSnapshot(alias)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void terminalReceiptSnapshotMustMatchEveryRecoverableJournalField() { + for (String mismatchedField : + new String[] { + "operationId", + "destinationId", + "publishedFileName", + "byteSize", + "rowCount", + "columnCount", + "sha256", + "formulaMitigatedCount", + "publishedAt", + "routeToken", + "fileId" + }) { + String snapshot = codec.encodeReceiptSnapshot(receiptVariant(mismatchedField)); + assertThatThrownBy(() -> publishedRecord(snapshot)) + .as("mismatched %s", mismatchedField) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void opaqueOperationAndVersionValuesAreNotMistakenForInternalLocators() { + FilePublishReceipt opaqueReceipt = + new FilePublishReceipt( + new FilePublishOperationId("customer/order:01"), + referenceCodec.encode("routea1", FILE_ID), + new FileDestinationId("local-export"), + "report.csv", + new FileVersion("etag/2026:07"), + "csv-rfc4180", + "text/csv", + "UTF-8", + 42, + 1, + 2, + DIGEST_C, + PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + 0); + + String snapshot = codec.encodeReceiptSnapshot(opaqueReceipt); + + assertThat(codec.decodeReceiptSnapshot(snapshot)).isEqualTo(opaqueReceipt); + assertThatThrownBy(() -> withManifestLocator("customer/order:01")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void encodedRecordSizeIsBoundedAndOversizedInputFailsClosed() { + assertThat(codec.encodeOperation(publishedRecord(codec.encodeReceiptSnapshot(receipt())))) + .hasSizeLessThanOrEqualTo(16_384); + assertThat(codec.encodeManifest(manifest())).hasSizeLessThanOrEqualTo(16_384); + assertThat(codec.encodeReference(referenceRecord())).hasSizeLessThanOrEqualTo(16_384); + + byte[] oversized = new byte[16_385]; + Arrays.fill(oversized, (byte) ' '); + assertThatThrownBy(() -> codec.decodeOperation(oversized)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> codec.decodeManifest(oversized)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> codec.decodeReference(oversized)) + .isInstanceOf(IllegalArgumentException.class); + } + + private DurablePublicationRecord publishedRecord(String snapshot) { + return operation( + 6, + DurablePublicationRecord.State.PUBLISHED, + 42, + 1, + 2, + DIGEST_C, + SEALED_AT, + DIGEST_A, + DIGEST_B, + PUBLISHED_AT, + "", + snapshot); + } + + private DurablePublicationRecord operation( + long revision, + DurablePublicationRecord.State state, + long byteSize, + long rowCount, + int columnCount, + String sha256, + Instant sealedAt, + String manifestDigest, + String referenceDigest, + Instant publishedAt, + String failureCode, + String receiptSnapshot) { + return operationWithFormulaCount( + revision, + state, + byteSize, + rowCount, + columnCount, + sha256, + 0, + sealedAt, + manifestDigest, + referenceDigest, + publishedAt, + failureCode, + receiptSnapshot); + } + + private DurablePublicationRecord operationWithFormulaCount( + long revision, + DurablePublicationRecord.State state, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + Instant sealedAt, + String manifestDigest, + String referenceDigest, + Instant publishedAt, + String failureCode, + String receiptSnapshot) { + return new DurablePublicationRecord( + 2, + revision, + state, + "operation-01", + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "operation-01.part", + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + manifestDigest, + referenceDigest, + CREATED_AT, + sealedAt, + publishedAt, + failureCode, + receiptSnapshot); + } + + private FilePublishReceipt receipt() { + return receiptWithPublishedFileName("report.csv"); + } + + private FilePublishReceipt receiptWithPublishedFileName(String publishedFileName) { + return new FilePublishReceipt( + new FilePublishOperationId("operation-01"), + referenceCodec.encode("routea1", FILE_ID), + new FileDestinationId("local-export"), + publishedFileName, + new FileVersion("version-01"), + "csv-rfc4180", + "text/csv", + "UTF-8", + 42, + 1, + 2, + DIGEST_C, + PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + 0); + } + + private FilePublishReceipt receiptWithCounts( + long rowCount, int columnCount, long formulaMitigatedCount) { + return new FilePublishReceipt( + new FilePublishOperationId("operation-01"), + referenceCodec.encode("routea1", FILE_ID), + new FileDestinationId("local-export"), + "report.csv", + new FileVersion("version-01"), + "csv-rfc4180", + "text/csv", + "UTF-8", + 42, + rowCount, + columnCount, + DIGEST_C, + PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + formulaMitigatedCount); + } + + private FilePublishReceipt receiptVariant(String mismatchedField) { + PublishedFileReference reference = + switch (mismatchedField) { + case "routeToken" -> referenceCodec.encode("routeb2", FILE_ID); + case "fileId" -> referenceCodec.encode("routea1", "f".repeat(32)); + default -> referenceCodec.encode("routea1", FILE_ID); + }; + return new FilePublishReceipt( + new FilePublishOperationId( + mismatchedField.equals("operationId") ? "operation-02" : "operation-01"), + reference, + new FileDestinationId( + mismatchedField.equals("destinationId") ? "other-export" : "local-export"), + mismatchedField.equals("publishedFileName") ? "other.csv" : "report.csv", + new FileVersion("version-01"), + "csv-rfc4180", + "text/csv", + "UTF-8", + mismatchedField.equals("byteSize") ? 43 : 42, + mismatchedField.equals("rowCount") ? 2 : 1, + mismatchedField.equals("columnCount") ? 3 : 2, + mismatchedField.equals("sha256") ? DIGEST_B : DIGEST_C, + mismatchedField.equals("publishedAt") ? PUBLISHED_AT.plusSeconds(1) : PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + mismatchedField.equals("formulaMitigatedCount") ? 1 : 0); + } + + private PrivateFileManifest manifest() { + return manifestWith(DIGEST_B, "00112233445566778899aabbccddeeff.csv"); + } + + private PrivateFileManifest withManifestLocator(String locator) { + return manifestWith(DIGEST_B, locator); + } + + private PrivateFileManifest withManifestSchemaDigest(String digest) { + return manifestWith(digest, "00112233445566778899aabbccddeeff.csv"); + } + + private PrivateFileManifest manifestWith(String schemaDigest, String locator) { + return manifestWith(schemaDigest, locator, 1, 2, 0); + } + + private PrivateFileManifest manifestWithCounts( + long rowCount, int columnCount, long formulaMitigatedCount) { + return manifestWith( + DIGEST_B, + "00112233445566778899aabbccddeeff.csv", + rowCount, + columnCount, + formulaMitigatedCount); + } + + private PrivateFileManifest manifestWith( + String schemaDigest, + String locator, + long rowCount, + int columnCount, + long formulaMitigatedCount) { + return new PrivateFileManifest( + 1, + "operation-01", + FILE_ID, + "local-primary", + referenceCodec.encode("routea1", FILE_ID).value(), + DIGEST_A, + "local-export", + "worklog-v1", + 1, + schemaDigest, + "csv-rfc4180", + DIGEST_A, + "policy-v1", + DIGEST_B, + "report.csv", + "version-01", + "text/csv", + "UTF-8", + 42, + rowCount, + columnCount, + DIGEST_C, + formulaMitigatedCount, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + locator, + CREATED_AT, + PUBLISHED_AT); + } + + private String receiptSnapshotWithUnusedTrailingBits() { + for (int suffixLength = 0; suffixLength < 8; suffixLength++) { + FilePublishReceipt candidate = + new FilePublishReceipt( + new FilePublishOperationId("operation-01"), + referenceCodec.encode("routea1", FILE_ID), + new FileDestinationId("local-export"), + "report.csv", + new FileVersion("version-01"), + "csv-rfc4180" + "x".repeat(suffixLength), + "text/csv", + "UTF-8", + 42, + 1, + 2, + DIGEST_C, + PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + 0); + String snapshot = codec.encodeReceiptSnapshot(candidate); + int remainder = (snapshot.length() - "rsv1.".length()) % 4; + if (remainder == 2 || remainder == 3) { + return snapshot; + } + } + throw new AssertionError("could not construct a receipt snapshot with unused trailing bits"); + } + + private static String nonCanonicalTrailingBitAlias(String snapshot) { + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + int finalIndex = snapshot.length() - 1; + int value = alphabet.indexOf(snapshot.charAt(finalIndex)); + if (value < 0 || value == alphabet.length() - 1) { + throw new AssertionError("unexpected canonical base64url tail"); + } + return snapshot.substring(0, finalIndex) + alphabet.charAt(value + 1); + } + + private PublishedReferenceRecord referenceRecord() { + return new PublishedReferenceRecord( + 1, + FILE_ID, + "routea1", + referenceCodec.encode("routea1", FILE_ID).value(), + "operation-01", + "version-01", + DIGEST_A, + "00112233445566778899aabbccddeeff.csv", + "local-export", + "local-primary", + "report.csv", + "text/csv", + "UTF-8", + 42, + DIGEST_C, + PUBLISHED_AT); + } + + private static int fieldCount(byte[] encoded) { + String json = new String(encoded, StandardCharsets.UTF_8); + return json.split("\":", -1).length - 1; + } + + private static void assertClosedSchema( + byte[] canonical, + Decoder decoder, + String currentSchema, + String newerSchema, + String requiredField) { + String json = new String(canonical, StandardCharsets.UTF_8); + assertThatThrownBy( + () -> + decoder.decode( + json.replace(currentSchema, newerSchema).getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + decoder.decode( + json.substring(0, json.length() - 1) + .concat(",\"unknown\":\"value\"}") + .getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + decoder.decode( + json.substring(0, json.length() - 1) + .concat("," + requiredField + "}") + .getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + decoder.decode( + json.replaceFirst(",?" + java.util.regex.Pattern.quote(requiredField), "") + .getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(IllegalArgumentException.class); + } + + @FunctionalInterface + private interface Decoder { + Object decode(byte[] bytes); + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java new file mode 100644 index 0000000..b8e0e71 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java @@ -0,0 +1,473 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.CellType; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.LogicalFileName; +import dev.caskeleton.application.filepublication.SourceRevision; +import dev.caskeleton.application.filepublication.TabularCell.IntegerCell; +import dev.caskeleton.application.filepublication.TabularCell.TextCell; +import dev.caskeleton.application.filepublication.TabularRow; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Forked-process helper for Fileserver R2 crash and OS-lock qualification. + * + *

Its output is deliberately restricted to fixed protocol markers. In particular, it never + * writes the supplied root path or exception messages to a child-process log. + */ +final class FileserverCrashScenarioMain { + + static final int CRASH_EXIT_CODE = 91; + static final String RECOVERY_SUCCESS = "RECOVERY_SUCCESS"; + static final String RECOVERY_INDETERMINATE_QUARANTINED = "RECOVERY_INDETERMINATE_QUARANTINED"; + static final String LOCK_ACQUIRED = "LOCK_ACQUIRED"; + static final String LOCK_BUSY = "LOCK_BUSY"; + static final String LOCK_RELEASED = "LOCK_RELEASED"; + + private static final int SCENARIO_FAILURE_EXIT_CODE = 70; + private static final int ARGUMENT_FAILURE_EXIT_CODE = 64; + private static final String OPERATION_ID = "operation-r2-01"; + private static final String SOURCE_REVISION = "source-42"; + private static final byte[] EXPECTED_PAYLOAD = "id,note\n1,'=cmd\n".getBytes(UTF_8); + + private FileserverCrashScenarioMain() {} + + public static void main(String[] arguments) { + try { + run(arguments); + } catch (Throwable failure) { + System.out.println("SCENARIO_FAILED"); + System.out.flush(); + Runtime.getRuntime().halt(SCENARIO_FAILURE_EXIT_CODE); + } + } + + private static void run(String[] arguments) throws IOException { + if (arguments.length < 2) { + Runtime.getRuntime().halt(ARGUMENT_FAILURE_EXIT_CODE); + } + ScenarioMode mode = ScenarioMode.valueOf(arguments[0]); + Path root = Path.of(arguments[1]).toAbsolutePath().normalize(); + switch (mode) { + case CRASH -> { + if (arguments.length != 3) { + Runtime.getRuntime().halt(ARGUMENT_FAILURE_EXIT_CODE); + } + crash(root, CrashBoundary.valueOf(arguments[2])); + } + case RECOVER -> { + if (arguments.length != 2) { + Runtime.getRuntime().halt(ARGUMENT_FAILURE_EXIT_CODE); + } + recover(root); + } + case LOCK_HOLD -> { + if (arguments.length != 2) { + Runtime.getRuntime().halt(ARGUMENT_FAILURE_EXIT_CODE); + } + holdOperationLock(root); + } + case LOCK_TRY -> { + if (arguments.length != 2) { + Runtime.getRuntime().halt(ARGUMENT_FAILURE_EXIT_CODE); + } + tryOperationLock(root); + } + default -> Runtime.getRuntime().halt(ARGUMENT_FAILURE_EXIT_CODE); + } + } + + private static void crash(Path root, CrashBoundary boundary) throws IOException { + LocalPersistentPublicationTestFixture fixture = + LocalPersistentPublicationTestFixture.create(root); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane(context -> boundary.haltAt(context)); + LocalPersistentPayloadOperations payload = fixture.payload(context -> boundary.haltAt(context)); + fixture + .provider(controlPlane, payload, fixture::fileId) + .publish( + fixture.request(SOURCE_REVISION), + sink -> sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd"))))); + System.out.println("CRASH_BOUNDARY_NOT_REACHED"); + System.out.flush(); + Runtime.getRuntime().halt(SCENARIO_FAILURE_EXIT_CODE); + } + + private static void recover(Path root) throws IOException { + ScenarioRuntime runtime = ScenarioRuntime.open(root); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + try { + FilePublishReceipt receipt = + runtime + .provider( + () -> { + fileIdCalls.incrementAndGet(); + throw new AssertionError("recovery allocated a new file ID"); + }) + .publish( + runtime.request(), + sink -> { + producerCalls.incrementAndGet(); + throw new AssertionError("recovery replayed the producer"); + }); + require(producerCalls.get() == 0, "producer was replayed"); + require(fileIdCalls.get() == 0, "file ID was reallocated"); + requireExactReceiptAndArtifacts(runtime, receipt); + emit(RECOVERY_SUCCESS); + } catch (FilePublicationException failure) { + require( + failure.reason() == FilePublicationException.Reason.PUBLISH_INDETERMINATE, + "recovery failed with a non-indeterminate reason"); + require(producerCalls.get() == 0, "producer was replayed"); + require(fileIdCalls.get() == 0, "file ID was reallocated"); + DurablePublicationRecord retained = + runtime.controlPlane().findOperation(OPERATION_ID).orElseThrow(); + require( + retained.state() == DurablePublicationRecord.State.QUARANTINED, + "indeterminate recovery did not retain quarantine evidence"); + require(countRegularFiles(runtime.root().resolve("data")) == 0, "partial final data exists"); + emit(RECOVERY_INDETERMINATE_QUARANTINED); + } + } + + private static void holdOperationLock(Path root) throws IOException { + ScenarioRuntime runtime = ScenarioRuntime.open(root); + try (LocalPersistentControlPlane.OperationLock ignored = + runtime.controlPlane().acquireOperationLock(OPERATION_ID)) { + emit(LOCK_ACQUIRED); + try (BufferedReader input = new BufferedReader(new InputStreamReader(System.in, UTF_8))) { + String command = input.readLine(); + require("RELEASE".equals(command), "lock holder received an invalid command"); + } + } + emit(LOCK_RELEASED); + } + + private static void tryOperationLock(Path root) throws IOException { + Path lockFile = operationLockPath(root, OPERATION_ID); + require( + Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(lockFile), + "operation lock file is unavailable"); + try (FileChannel channel = + FileChannel.open( + lockFile, Set.of(StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)); + FileLock lock = channel.tryLock()) { + emit(lock == null ? LOCK_BUSY : LOCK_ACQUIRED); + } + } + + private static void requireExactReceiptAndArtifacts( + ScenarioRuntime runtime, FilePublishReceipt receipt) throws IOException { + String expectedFileName = + LocalPersistentRecoveryVerifier.generatedFileName( + LocalPersistentPublicationTestFixture.FILE_ID); + String expectedDigest = LocalPersistentPublicationTestFixture.sha256(EXPECTED_PAYLOAD); + require(receipt.operationId().value().equals(OPERATION_ID), "operation ID changed"); + require( + receipt.destinationId().equals(LocalPersistentPublicationTestFixture.DESTINATION), + "destination changed"); + require(receipt.publishedFileName().equals(expectedFileName), "file name changed"); + require(receipt.version().value().equals(expectedDigest), "version changed"); + require(receipt.formatProfileId().equals("csv-rfc4180-v1"), "format changed"); + require(receipt.mediaType().equals("text/csv"), "media type changed"); + require(receipt.charset().equals(UTF_8.name()), "charset changed"); + require(receipt.byteSize() == EXPECTED_PAYLOAD.length, "byte size changed"); + require(receipt.dataRowCount() == 1, "row count changed"); + require(receipt.columnCount() == 2, "column count changed"); + require(receipt.sha256().equals(expectedDigest), "payload digest changed"); + require( + receipt.publishedAt().equals(LocalPersistentPublicationTestFixture.PUBLISHED_AT), + "publication time changed"); + require( + receipt.publicationGuarantee() == PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + "publication guarantee changed"); + require( + receipt.durabilityGuarantee() == DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + "durability guarantee changed"); + require(receipt.formulaMitigatedCount() == 1, "formula count changed"); + + R2PublishedReferenceCodec.DecodedReference reference = + new R2PublishedReferenceCodec() + .decode(receipt.reference(), Set.of(runtime.destination().routeToken())); + require( + reference.fileId().equals(LocalPersistentPublicationTestFixture.FILE_ID), + "reference file ID changed"); + Path data = + runtime + .root() + .resolve("data") + .resolve(LocalPersistentPublicationTestFixture.FILE_ID.substring(0, 2)) + .resolve(expectedFileName); + require( + Arrays.equals(Files.readAllBytes(data), EXPECTED_PAYLOAD), + "published data is partial or different"); + require(countRegularFiles(runtime.root().resolve("data")) == 1, "unexpected final data exists"); + + DurablePublicationRecord operation = + runtime.controlPlane().findOperation(OPERATION_ID).orElseThrow(); + require( + operation.state() == DurablePublicationRecord.State.PUBLISHED, + "terminal operation was not restored"); + require( + runtime + .controlPlane() + .findManifest(LocalPersistentPublicationTestFixture.FILE_ID) + .isPresent(), + "manifest is absent"); + require( + runtime + .controlPlane() + .findReference(LocalPersistentPublicationTestFixture.FILE_ID) + .isPresent(), + "reference is absent"); + } + + private static long countRegularFiles(Path directory) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return 0; + } + try (var files = Files.walk(directory)) { + return files.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)).count(); + } + } + + private static Path operationLockPath(Path root, String operationId) { + String token = sha256(operationId); + return root.resolve(".ca-fileserver") + .resolve("operations") + .resolve(token.substring(0, 2)) + .resolve(token + ".lock"); + } + + private static String sha256(String value) { + try { + return HexFormat.of() + .formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(UTF_8))); + } catch (NoSuchAlgorithmException failure) { + throw new AssertionError("SHA-256 must be available", failure); + } + } + + private static void emit(String marker) { + System.out.println(marker); + System.out.flush(); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } + + enum CrashBoundary { + J_WRITING, + STAGE_FORCED, + J_SEALED, + DATA_LINKED, + DATA_DIRECTORY_FORCED, + MANIFEST_FORCED, + MANIFEST_DIRECTORY_FORCED, + REFERENCE_FORCED, + REFERENCE_DIRECTORY_FORCED, + TERMINAL_JOURNAL_FORCED, + TERMINAL_JOURNAL_DIRECTORY_FORCED; + + void haltAt(LocalPersistentControlPlane.FaultContext context) { + if (matches(context)) { + Runtime.getRuntime().halt(CRASH_EXIT_CODE); + } + } + + void haltAt(LocalPersistentPayloadOperations.FaultContext context) { + if (matches(context)) { + Runtime.getRuntime().halt(CRASH_EXIT_CODE); + } + } + + boolean permitsIndeterminateQuarantine() { + return this == J_WRITING || this == STAGE_FORCED; + } + + private boolean matches(LocalPersistentControlPlane.FaultContext context) { + return switch (this) { + case J_WRITING -> + operationAt( + context, + DurablePublicationRecord.State.WRITING, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + case J_SEALED -> + operationAt( + context, + DurablePublicationRecord.State.SEALED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + case MANIFEST_FORCED -> + immutableAt( + context, + LocalPersistentControlPlane.ControlRecordKind.MANIFEST, + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED); + case MANIFEST_DIRECTORY_FORCED -> + immutableAt( + context, + LocalPersistentControlPlane.ControlRecordKind.MANIFEST, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + case REFERENCE_FORCED -> + immutableAt( + context, + LocalPersistentControlPlane.ControlRecordKind.REFERENCE, + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED); + case REFERENCE_DIRECTORY_FORCED -> + immutableAt( + context, + LocalPersistentControlPlane.ControlRecordKind.REFERENCE, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + case TERMINAL_JOURNAL_FORCED -> + operationAt( + context, + DurablePublicationRecord.State.PUBLISHED, + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED); + case TERMINAL_JOURNAL_DIRECTORY_FORCED -> + operationAt( + context, + DurablePublicationRecord.State.PUBLISHED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + default -> false; + }; + } + + private boolean matches(LocalPersistentPayloadOperations.FaultContext context) { + return switch (this) { + case STAGE_FORCED -> + context.point() == LocalPersistentPayloadOperations.FaultPoint.STAGE_FORCED; + case DATA_LINKED -> + context.point() == LocalPersistentPayloadOperations.FaultPoint.DATA_LINKED; + case DATA_DIRECTORY_FORCED -> + context.point() == LocalPersistentPayloadOperations.FaultPoint.DATA_DIRECTORY_FORCED; + default -> false; + }; + } + + private static boolean operationAt( + LocalPersistentControlPlane.FaultContext context, + DurablePublicationRecord.State state, + LocalPersistentControlPlane.FaultPoint point) { + Optional operation = context.operation(); + return context.recordKind() == LocalPersistentControlPlane.ControlRecordKind.OPERATION + && context.boundary() == point + && operation.isPresent() + && operation.orElseThrow().state() == state; + } + + private static boolean immutableAt( + LocalPersistentControlPlane.FaultContext context, + LocalPersistentControlPlane.ControlRecordKind kind, + LocalPersistentControlPlane.FaultPoint point) { + return context.recordKind() == kind + && context.operation().isEmpty() + && context.boundary() == point; + } + } + + private enum ScenarioMode { + CRASH, + RECOVER, + LOCK_HOLD, + LOCK_TRY + } + + private record ScenarioRuntime( + Path root, + CompiledFileDestination destination, + LocalPersistentControlPlane controlPlane, + LocalPersistentPayloadOperations payload) { + + private static ScenarioRuntime open(Path root) throws IOException { + Path normalizedRoot = root.toAbsolutePath().normalize(); + FileStore store = Files.getFileStore(normalizedRoot); + Set rootMode = PosixFilePermissions.fromString("rwx------"); + FileDestinationId destinationId = LocalPersistentPublicationTestFixture.DESTINATION; + CompiledFileDestination destination = + new CompiledFileDestination( + destinationId, + "local-primary", + normalizedRoot, + 10_000, + 16 * 1024 * 1024, + store.name(), + store.type(), + LocalPersistentPublicationTestFixture.SENTINEL_NAME, + LocalPersistentPublicationTestFixture.sha256( + LocalPersistentPublicationTestFixture.SENTINEL_CONTENT), + Files.getOwner(normalizedRoot).getName(), + "0700", + rootMode, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destination); + return new ScenarioRuntime( + normalizedRoot, + destination, + new LocalPersistentControlPlane(attestor, evidence), + new LocalPersistentPayloadOperations(attestor, evidence)); + } + + private FilePublishRequest request() { + return new FilePublishRequest( + new FilePublishOperationId(OPERATION_ID), + LocalPersistentPublicationTestFixture.DESTINATION, + new LogicalFileName("report"), + new SourceRevision(SOURCE_REVISION), + new ExportSchema( + "worklog-v1", + 1, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 64), + new Column("note", CellType.TEXT, false, FormulaPolicy.MITIGATE, 256))), + "csv-rfc4180-v1"); + } + + private LocalPersistentPublicationProvider provider( + LocalPersistentPublicationProvider.FileIdGenerator fileIds) { + LocalPersistentPublicationProvider.DestinationRuntime runtime = + new LocalPersistentPublicationProvider.DestinationRuntime( + destination, controlPlane, payload); + return new LocalPersistentPublicationProvider( + Map.of(LocalPersistentPublicationTestFixture.DESTINATION, runtime), + LocalPersistentPublicationTestFixture.FIXED_CLOCK, + fileIds); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java new file mode 100644 index 0000000..352f7c1 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2ConfigTest.java @@ -0,0 +1,530 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.fileexport.FileExportPort; +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.CellType; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublicationPort; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.LogicalFileName; +import dev.caskeleton.application.filepublication.SourceRevision; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class FileserverR2ConfigTest { + + private static final String SENTINEL_NAME = ".ca-fileserver-volume"; + private static final byte[] SENTINEL_CONTENT = "fileserver-r2-config-test\n".getBytes(UTF_8); + + @TempDir Path temporaryDirectory; + + private final ApplicationContextRunner r2Runner = + new ApplicationContextRunner().withUserConfiguration(FileserverR2Config.class); + + @Test + void disabledR2CreatesNoPortOrFilesystemSideEffect() { + Path absentRoot = temporaryDirectory.resolve("disabled-root").toAbsolutePath().normalize(); + + r2Runner + .withPropertyValues( + "app.fileserver.enabled=false", + destinationProperty("local-export", "provider-ref", "local-primary"), + providerProperty("local-primary", "type", "local-persistent"), + providerProperty("local-primary", "root-directory", absentRoot.toString())) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(FilePublicationPort.class); + assertThat(absentRoot).doesNotExist(); + }); + } + + @Test + void enabledR2CreatesExactlyOneRoutingPortAndAttestsTheRealPosixRoot() throws IOException { + RootFixture root = preProvisionedRoot("enabled"); + + r2Runner + .withPropertyValues(validProperties(root, "local-export")) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(FilePublicationPort.class); + assertThat(context.getBean(FilePublicationPort.class)) + .isInstanceOf(RoutingFilePublicationAdapter.class); + assertThat(root.path().resolve(".ca-fileserver")).isDirectory(); + assertThat(root.path().resolve("data")).isDirectory(); + }); + } + + @Test + void requestForUnknownDestinationFailsBeforeProducerInvocation() throws IOException { + RootFixture root = preProvisionedRoot("unknown-destination"); + + r2Runner + .withPropertyValues(validProperties(root, "configured-export")) + .run( + context -> { + assertThat(context).hasNotFailed(); + AtomicInteger producerCalls = new AtomicInteger(); + FilePublicationPort port = context.getBean(FilePublicationPort.class); + + assertThatThrownBy( + () -> + port.publish( + request("unknown-export"), + sink -> { + producerCalls.incrementAndGet(); + throw new AssertionError( + "an unknown destination must not invoke the producer"); + })) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.INVALID_REQUEST)); + assertThat(producerCalls).hasValue(0); + }); + } + + @Test + void destinationsBoundToOneProviderReuseOneProviderRuntime() throws Exception { + RootFixture root = preProvisionedRoot("shared-provider"); + String[] properties = + concatenate( + validProperties(root, "daily-export"), + destinationProperties("monthly-export", "local-primary")); + + r2Runner + .withPropertyValues(properties) + .run( + context -> { + assertThat(context).hasNotFailed(); + RoutingFilePublicationAdapter router = + context.getBean(RoutingFilePublicationAdapter.class); + Map routes = routes(router); + + assertThat(routes).hasSize(2); + assertThat(routes.keySet()) + .containsExactlyInAnyOrder( + new FileDestinationId("daily-export"), + new FileDestinationId("monthly-export")); + FilePublicationProvider daily = routes.get(new FileDestinationId("daily-export")); + assertThat(routes.get(new FileDestinationId("monthly-export"))).isSameAs(daily); + }); + } + + @Test + void destinationsBoundToDifferentProvidersUseDifferentProviderRuntimes() throws Exception { + RootFixture primaryRoot = preProvisionedRoot("multi-provider-primary"); + RootFixture secondaryRoot = preProvisionedRoot("multi-provider-secondary"); + String[] properties = + concatenate( + validProperties(primaryRoot, "daily-export"), + destinationProperties("monthly-export", "secondary"), + providerProperties(secondaryRoot, "secondary", "local-persistent")); + + r2Runner + .withPropertyValues(properties) + .run( + context -> { + assertThat(context).hasNotFailed(); + RoutingFilePublicationAdapter router = + context.getBean(RoutingFilePublicationAdapter.class); + Map routes = routes(router); + + assertThat(routes).hasSize(2); + assertThat(routes.get(new FileDestinationId("daily-export"))) + .isNotSameAs(routes.get(new FileDestinationId("monthly-export"))); + assertThat(primaryRoot.path().resolve(".ca-fileserver")).isDirectory(); + assertThat(secondaryRoot.path().resolve(".ca-fileserver")).isDirectory(); + }); + } + + @Test + void enablingLegacyR1AndR2TogetherFailsBeforeEitherFilesystemIsMutated() throws IOException { + assertAmbiguousActivationHasNoSideEffects( + preProvisionedRoot("ambiguous-r2-r1-first"), + temporaryDirectory.resolve("ambiguous-r1-r1-first").toAbsolutePath().normalize(), + temporaryDirectory.resolve("ambiguous-legacy-r1-first").toAbsolutePath().normalize(), + FileExportConfig.class, + FileserverR2Config.class); + assertAmbiguousActivationHasNoSideEffects( + preProvisionedRoot("ambiguous-r2-r2-first"), + temporaryDirectory.resolve("ambiguous-r1-r2-first").toAbsolutePath().normalize(), + temporaryDirectory.resolve("ambiguous-legacy-r2-first").toAbsolutePath().normalize(), + FileserverR2Config.class, + FileExportConfig.class); + } + + private static void assertAmbiguousActivationHasNoSideEffects( + RootFixture r2Root, + Path r1Root, + Path legacyRoot, + Class firstConfiguration, + Class secondConfiguration) { + new ApplicationContextRunner() + .withUserConfiguration(firstConfiguration, secondConfiguration) + .withPropertyValues(validProperties(r2Root, "local-export")) + .withPropertyValues( + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.legacy-enabled=true", + "ca-skeleton.fileserver.base-directory=" + r1Root, + "ca-skeleton.fileserver.legacy-base-directory=" + legacyRoot) + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "ca-skeleton.fileserver.enabled and app.fileserver.enabled cannot both be true"); + assertThat(r2Root.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(r2Root.path().resolve("data")).doesNotExist(); + assertThat(r1Root).doesNotExist(); + assertThat(legacyRoot).doesNotExist(); + }); + } + + @Test + void legacyR1AloneRemainsAvailableWhenR2IsDisabled() { + Path r1Root = temporaryDirectory.resolve("r1-only").toAbsolutePath().normalize(); + + new ApplicationContextRunner() + .withUserConfiguration(FileserverR2Config.class, FileExportConfig.class) + .withPropertyValues( + "app.fileserver.enabled=false", + "ca-skeleton.fileserver.enabled=true", + "ca-skeleton.fileserver.base-directory=" + r1Root) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(FilePublicationPort.class); + assertThat(context.getBean(FilePublicationPort.class)) + .isInstanceOf(LocalFilePublicationAdapter.class); + assertThat(context).doesNotHaveBean(RoutingFilePublicationAdapter.class); + assertThat(context).doesNotHaveBean(FileExportPort.class); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"shared-mounted", "sftp"}) + void configuredButUnimplementedProviderTypeFailsWithoutCreatingItsRoot(String providerType) { + Path absentRoot = + temporaryDirectory.resolve("unsupported-" + providerType).toAbsolutePath().normalize(); + String[] properties = + validProperties(absentRoot, "local-export", "local-primary", providerType); + + r2Runner + .withPropertyValues(properties) + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "provider local-primary type must be exactly local-persistent"); + assertThat(absentRoot).doesNotExist(); + }); + } + + @Test + void providerTypeHasNoDefault() { + Path absentRoot = temporaryDirectory.resolve("missing-type").toAbsolutePath().normalize(); + String[] properties = + without( + validProperties(absentRoot, "local-export", "local-primary", "local-persistent"), + providerProperty("local-primary", "type", "local-persistent")); + + r2Runner + .withPropertyValues(properties) + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "provider local-primary type must be exactly local-persistent"); + assertThat(absentRoot).doesNotExist(); + }); + } + + @Test + void missingAndUnknownProviderReferencesFailBeforeAttestation() throws IOException { + RootFixture missingReferenceRoot = preProvisionedRoot("missing-provider-ref"); + String[] missingReference = + without( + validProperties(missingReferenceRoot, "local-export"), + destinationProperty("local-export", "provider-ref", "local-primary")); + + r2Runner + .withPropertyValues(missingReference) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(missingReferenceRoot.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(missingReferenceRoot.path().resolve("data")).doesNotExist(); + }); + + RootFixture unknownReferenceRoot = preProvisionedRoot("unknown-provider-ref"); + String[] unknownReference = + replace( + validProperties(unknownReferenceRoot, "local-export"), + destinationProperty("local-export", "provider-ref", "local-primary"), + destinationProperty("local-export", "provider-ref", "missing-provider")); + + r2Runner + .withPropertyValues(unknownReference) + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "destination local-export references unknown provider-ref missing-provider"); + assertThat(unknownReferenceRoot.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(unknownReferenceRoot.path().resolve("data")).doesNotExist(); + }); + } + + @Test + void unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored() throws IOException { + RootFixture root = preProvisionedRoot("unknown-field"); + + r2Runner + .withPropertyValues(validProperties(root, "local-export")) + .withPropertyValues(providerProperty("local-primary", "strict-path-securty", "true")) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(root.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(root.path().resolve("data")).doesNotExist(); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"app.fileserver.enabld=true", "app.fileserver.enabled=not-a-boolean"}) + void invalidOrMisspelledSelectorFailsStrictBindingBeforeFilesystemSideEffects(String selector) + throws IOException { + RootFixture root = preProvisionedRoot("invalid-selector"); + String[] configured = + replace(validProperties(root, "local-export"), "app.fileserver.enabled=true", selector); + + r2Runner + .withPropertyValues(configured) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(root.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(root.path().resolve("data")).doesNotExist(); + }); + } + + @Test + void differentProviderIdsCannotOwnTheSameNormalizedRoot() throws IOException { + RootFixture root = preProvisionedRoot("duplicate-root"); + String[] firstProvider = validProperties(root, "first-export"); + String[] secondProvider = + concatenate( + destinationProperties("second-export", "secondary"), + providerProperties(root, "secondary", "local-persistent")); + + r2Runner + .withPropertyValues(concatenate(firstProvider, secondProvider)) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(root.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(root.path().resolve("data")).doesNotExist(); + }); + } + + @Test + void unreferencedProviderStillCannotClaimAnotherProviderRoot() throws IOException { + RootFixture root = preProvisionedRoot("unreferenced-duplicate-root"); + String[] configured = + concatenate( + validProperties(root, "first-export"), + providerProperties(root, "unreferenced-secondary", "local-persistent")); + + r2Runner + .withPropertyValues(configured) + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasRootCauseMessage( + "different fileserver provider IDs cannot share one root directory"); + assertThat(root.path().resolve(".ca-fileserver")).doesNotExist(); + assertThat(root.path().resolve("data")).doesNotExist(); + }); + } + + @SuppressWarnings("unchecked") + private static Map routes( + RoutingFilePublicationAdapter router) { + try { + Field routes = RoutingFilePublicationAdapter.class.getDeclaredField("routes"); + routes.setAccessible(true); + return (Map) routes.get(router); + } catch (ReflectiveOperationException exception) { + throw new LinkageError("routing adapter route inspection failed", exception); + } + } + + private RootFixture preProvisionedRoot(String name) throws IOException { + Path root = temporaryDirectory.resolve(name).toAbsolutePath().normalize(); + Files.createDirectory( + root, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path sentinel = + Files.createFile( + root.resolve(SENTINEL_NAME), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + Files.write(sentinel, SENTINEL_CONTENT); + FileStore fileStore = Files.getFileStore(root); + return new RootFixture( + root, + fileStore.name(), + fileStore.type(), + Files.getOwner(root).getName(), + sha256(SENTINEL_CONTENT)); + } + + private static String[] validProperties(RootFixture root, String destinationId) { + return validProperties(root.path(), destinationId, "local-primary", "local-persistent"); + } + + private static String[] validProperties( + Path root, String destinationId, String providerId, String providerType) { + try { + FileStore fileStore = Files.getFileStore(root.getParent()); + return concatenate( + new String[] {"app.fileserver.enabled=true"}, + destinationProperties(destinationId, providerId), + providerProperties( + root, + providerId, + providerType, + fileStore.name(), + fileStore.type(), + Files.getOwner(root.getParent()).getName(), + sha256(SENTINEL_CONTENT))); + } catch (IOException exception) { + throw new AssertionError("test filesystem evidence must be readable", exception); + } + } + + private static String[] providerProperties( + RootFixture root, String providerId, String providerType) { + return providerProperties( + root.path(), + providerId, + providerType, + root.fileStoreName(), + root.fileStoreType(), + root.owner(), + root.sentinelSha256()); + } + + private static String[] providerProperties( + Path root, + String providerId, + String providerType, + String fileStoreName, + String fileStoreType, + String owner, + String sentinelSha256) { + return new String[] { + providerProperty(providerId, "type", providerType), + providerProperty(providerId, "root-directory", root.toString()), + providerProperty(providerId, "auto-create", "false"), + providerProperty(providerId, "strict-path-security", "true"), + providerProperty(providerId, "expected-file-store-name", fileStoreName), + providerProperty(providerId, "expected-file-store-type", fileStoreType), + providerProperty(providerId, "mount-sentinel-name", SENTINEL_NAME), + providerProperty(providerId, "mount-sentinel-sha256", sentinelSha256), + providerProperty(providerId, "expected-owner", owner), + providerProperty(providerId, "maximum-root-mode", "0700") + }; + } + + private static String[] destinationProperties(String destinationId, String providerId) { + return new String[] { + destinationProperty(destinationId, "provider-ref", providerId), + destinationProperty(destinationId, "required-publication", "unique-atomic-create"), + destinationProperty(destinationId, "required-durability", "file-and-directory-sync"), + destinationProperty(destinationId, "maximum-rows", "1000"), + destinationProperty(destinationId, "maximum-encoded-bytes", "1048576") + }; + } + + private static String destinationProperty(String destinationId, String property, String value) { + return "app.fileserver.destinations." + destinationId + "." + property + "=" + value; + } + + private static String providerProperty(String providerId, String property, String value) { + return "app.fileserver.providers." + providerId + "." + property + "=" + value; + } + + private static FilePublishRequest request(String destinationId) { + return new FilePublishRequest( + new FilePublishOperationId("routing-test-operation"), + new FileDestinationId(destinationId), + new LogicalFileName("routing-test"), + new SourceRevision("source-1"), + new ExportSchema( + "routing-schema", + 1, + List.of(new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 32))), + "csv-rfc4180-v1"); + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 must be available", exception); + } + } + + private static String[] concatenate(String[]... groups) { + return java.util.Arrays.stream(groups).flatMap(java.util.Arrays::stream).toArray(String[]::new); + } + + private static String[] without(String[] values, String excluded) { + return java.util.Arrays.stream(values) + .filter(value -> !value.equals(excluded)) + .toArray(String[]::new); + } + + private static String[] replace(String[] values, String original, String replacement) { + return java.util.Arrays.stream(values) + .map(value -> value.equals(original) ? replacement : value) + .toArray(String[]::new); + } + + private record RootFixture( + Path path, String fileStoreName, String fileStoreType, String owner, String sentinelSha256) {} +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java new file mode 100644 index 0000000..e5a6fdf --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlaneTest.java @@ -0,0 +1,2083 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FileVersion; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +@EnabledOnOs(OS.LINUX) +class LocalPersistentControlPlaneTest { + + private static final String OPERATION_ID = "customer/order:01"; + private static final String FILE_ID = "00112233445566778899aabbccddeeff"; + private static final String DIGEST_A = "a".repeat(64); + private static final String DIGEST_B = "b".repeat(64); + private static final String DIGEST_C = "c".repeat(64); + private static final String SENTINEL_NAME = ".ca-fileserver-volume"; + private static final byte[] SENTINEL_CONTENT = + "fileserver-r2-control-plane".getBytes(StandardCharsets.UTF_8); + private static final Instant CREATED_AT = Instant.parse("2026-07-28T01:02:03Z"); + private static final Instant SEALED_AT = Instant.parse("2026-07-28T01:02:04Z"); + private static final Instant PUBLISHED_AT = Instant.parse("2026-07-28T01:02:05Z"); + + @TempDir Path tempDirectory; + private final AtomicInteger rootSequence = new AtomicInteger(); + + @Test + void storesAndDirectlyLoadsOperationManifestAndReferenceRecords() throws IOException { + ControlFixture fixture = controlFixture(); + Path root = fixture.root(); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + DurablePublicationRecord operation = writingRecord(); + PrivateFileManifest manifest = manifest(); + PublishedReferenceRecord reference = referenceRecord(); + + controlPlane.storeOperation(operation); + controlPlane.storeManifest(manifest); + controlPlane.storeReference(reference); + + LocalPersistentControlPlane reopened = fixture.controlPlane(); + assertThat(reopened.findOperation(OPERATION_ID)).contains(operation); + assertThat(reopened.findManifest(FILE_ID)).contains(manifest); + assertThat(reopened.findReference(FILE_ID)).contains(reference); + String operationToken = sha256(OPERATION_ID.getBytes(StandardCharsets.UTF_8)); + assertThat( + root.resolve(".ca-fileserver/operations") + .resolve(operationToken.substring(0, 2)) + .resolve(operationToken + ".json")) + .isRegularFile(); + assertThat(root.resolve(".ca-fileserver/operations/customer")).doesNotExist(); + } + + @Test + void typedOperationLookupDispatchesCanonicalR2AndTerminalR1FromTheSameHashedPath() + throws IOException { + ControlFixture r2Fixture = controlFixture(); + r2Fixture.controlPlane().storeOperation(writingRecord()); + + assertThat(r2Fixture.controlPlane().findStoredOperation(OPERATION_ID)) + .contains(new LocalPersistentControlPlane.R2StoredOperationRecord(writingRecord())); + + ControlFixture r1Fixture = controlFixture(); + LocalPublicationJournalRecord published = publishedR1Record(OPERATION_ID); + writeR1Operation( + r1Fixture.root(), OPERATION_ID, LocalPublicationJournalCodec.encode(published)); + byte[] before = Files.readAllBytes(operationPath(r1Fixture.root(), OPERATION_ID)); + + assertThat(r1Fixture.controlPlane().findStoredOperation(OPERATION_ID)) + .contains(new LocalPersistentControlPlane.R1StoredOperationRecord(published)); + assertThat(Files.readAllBytes(operationPath(r1Fixture.root(), OPERATION_ID))).isEqualTo(before); + assertThat(r1Fixture.root().resolve(".ca-fileserver/manifests")).isEmptyDirectory(); + assertThat(r1Fixture.root().resolve(".ca-fileserver/references")).isEmptyDirectory(); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> r1Fixture.controlPlane().findOperation(OPERATION_ID)); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> r1Fixture.controlPlane().storeOperation(writingRecord())); + assertThat(Files.readAllBytes(operationPath(r1Fixture.root(), OPERATION_ID))).isEqualTo(before); + } + + @Test + void typedOperationLookupRejectsMalformedNonCanonicalNonTerminalAndWrongIdentityR1() + throws IOException { + byte[] canonical = LocalPublicationJournalCodec.encode(publishedR1Record(OPERATION_ID)); + byte[] whitespace = + new String(canonical, StandardCharsets.UTF_8) + .replace("{", "{ ") + .getBytes(StandardCharsets.UTF_8); + byte[] reordered = + new String(canonical, StandardCharsets.UTF_8) + .replace( + "{\"schemaVersion\":1,\"state\":\"PUBLISHED\"", + "{\"state\":\"PUBLISHED\",\"schemaVersion\":1") + .getBytes(StandardCharsets.UTF_8); + byte[] escaped = + new String(canonical, StandardCharsets.UTF_8) + .replace( + "\"operationId\":\"customer/order:01\"", "\"operationId\":\"customer\\/order:01\"") + .getBytes(StandardCharsets.UTF_8); + LocalPublicationJournalRecord sealed = + LocalPublicationJournalRecord.sealed( + OPERATION_ID, DIGEST_A, "report.csv", "customer-order-01.part", 42, 1, 2, DIGEST_C, 0); + List invalid = + List.of( + new byte[] {(byte) 0xc3, (byte) 0x28}, + whitespace, + reordered, + escaped, + LocalPublicationJournalCodec.encode(sealed), + LocalPublicationJournalCodec.encode( + LocalPublicationJournalRecord.writing( + OPERATION_ID, DIGEST_A, "report.csv", "customer-order-01.part")), + LocalPublicationJournalCodec.encode(publishedR1Record("other/order:02")), + "{}".getBytes(StandardCharsets.UTF_8), + new String(canonical, StandardCharsets.UTF_8) + .replace("\"schemaVersion\":1", "\"schemaVersion\":3") + .getBytes(StandardCharsets.UTF_8)); + + for (byte[] encoded : invalid) { + ControlFixture fixture = controlFixture(); + writeR1Operation(fixture.root(), OPERATION_ID, encoded); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> fixture.controlPlane().findStoredOperation(OPERATION_ID)); + } + } + + @Test + void contextualFaultsIdentifyRecordBoundaryIdentityAndApplicableOperationRevision() + throws IOException { + ControlFixture fixture = controlFixture(); + List contexts = new ArrayList<>(); + LocalPersistentControlPlane controlPlane = fixture.contextualControlPlane(contexts::add); + + controlPlane.storeOperation(writingRecord()); + controlPlane.storeManifest(manifest()); + controlPlane.storeReference(referenceRecord()); + + assertThat(contexts) + .containsExactly( + operationFault(LocalPersistentControlPlane.FaultPoint.TEMP_FORCED), + operationFault(LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED), + operationFault(LocalPersistentControlPlane.FaultPoint.PARENT_FORCED), + immutableFault( + LocalPersistentControlPlane.ControlRecordKind.MANIFEST, + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED), + immutableFault( + LocalPersistentControlPlane.ControlRecordKind.MANIFEST, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED), + immutableFault( + LocalPersistentControlPlane.ControlRecordKind.MANIFEST, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED), + immutableFault( + LocalPersistentControlPlane.ControlRecordKind.REFERENCE, + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED), + immutableFault( + LocalPersistentControlPlane.ControlRecordKind.REFERENCE, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED), + immutableFault( + LocalPersistentControlPlane.ControlRecordKind.REFERENCE, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED)); + } + + @Test + void contextualParentFaultRetryReportsTheSameRepairSubjectAndPreservesCleanupOrder() + throws IOException { + ControlFixture fixture = controlFixture(); + List contexts = new ArrayList<>(); + AtomicBoolean failOnce = new AtomicBoolean(true); + InjectedFault original = new InjectedFault(); + LocalPersistentControlPlane controlPlane = + fixture.contextualControlPlane( + context -> { + contexts.add(context); + if (context.boundary() == LocalPersistentControlPlane.FaultPoint.PARENT_FORCED + && failOnce.getAndSet(false)) { + throw original; + } + }); + + assertThatThrownBy(() -> controlPlane.storeOperation(writingRecord())).isSameAs(original); + controlPlane.storeOperation(writingRecord()); + + assertThat(contexts) + .containsExactly( + operationFault(LocalPersistentControlPlane.FaultPoint.TEMP_FORCED), + operationFault(LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED), + operationFault(LocalPersistentControlPlane.FaultPoint.PARENT_FORCED), + operationFault(LocalPersistentControlPlane.FaultPoint.PARENT_FORCED)); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + } + + @Test + void initialOperationMustBeWritingAtItsMinimumRevision() throws IOException { + for (DurablePublicationRecord invalidInitial : + List.of( + sealedRecord(2), + progressedRecord(DurablePublicationRecord.State.PUBLISHED, 6), + quarantinedFrom(writingRecord(), 2), + writingRecord(2))) { + LocalPersistentControlPlane controlPlane = controlFixture().controlPlane(); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeOperation(invalidInitial)); + assertThat(controlPlane.findOperation(OPERATION_ID)).isEmpty(); + } + } + + @Test + void operationRevisionFingerprintIdentityAndStateAreMonotonic() throws IOException { + List points = new ArrayList<>(); + LocalPersistentControlPlane controlPlane = controlFixture().controlPlane(points::add); + DurablePublicationRecord original = storeSealedOperation(controlPlane); + + points.clear(); + controlPlane.storeOperation(original); + + assertThat(points).containsExactly(LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(original); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeOperation(writingRecord(1))); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> + controlPlane.storeOperation( + operationRecord( + 2, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT.minusSeconds(1)))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> + controlPlane.storeOperation( + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_C, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT))); + + for (DurablePublicationRecord incompatible : + List.of( + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + "other/order:02", + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "other-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "other-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + "f".repeat(32), + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routeb2", + "report.csv", + "customer-order-01.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "other.csv", + "customer-order-01.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "other.part", + CREATED_AT), + operationRecord( + 3, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v2", + DIGEST_C, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT))) { + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeOperation(OPERATION_ID, incompatible)); + } + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeOperation(writingRecord(3))); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(original); + } + + @Test + void operationTransitionsAreAdjacentAndPublishedIsTerminal() throws IOException { + LocalPersistentControlPlane jump = controlFixture().controlPlane(); + jump.storeOperation(writingRecord()); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> jump.storeOperation(progressedRecord(DurablePublicationRecord.State.PUBLISHED, 6))); + + LocalPersistentControlPlane sameState = controlFixture().controlPlane(); + storeSealedOperation(sameState); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> sameState.storeOperation(sealedRecord(3))); + + LocalPersistentControlPlane chain = controlFixture().controlPlane(); + chain.storeOperation(writingRecord()); + chain.storeOperation(sealedRecord(2)); + chain.storeOperation(progressedRecord(DurablePublicationRecord.State.DATA_PUBLISHED, 3)); + chain.storeOperation(progressedRecord(DurablePublicationRecord.State.MANIFEST_PUBLISHED, 4)); + chain.storeOperation(progressedRecord(DurablePublicationRecord.State.REFERENCE_PUBLISHED, 5)); + DurablePublicationRecord published = + progressedRecord(DurablePublicationRecord.State.PUBLISHED, 6); + chain.storeOperation(published); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> chain.storeOperation(progressedRecord(DurablePublicationRecord.State.PUBLISHED, 7))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> chain.storeOperation(quarantinedRecord(7))); + assertThat(chain.findOperation(OPERATION_ID)).contains(published); + } + + @Test + void everyNonTerminalStateCanOnlyEnterQuarantineOnceAndQuarantineIsTerminal() throws IOException { + List normalPath = + List.of( + writingRecord(), + sealedRecord(2), + progressedRecord(DurablePublicationRecord.State.DATA_PUBLISHED, 3), + progressedRecord(DurablePublicationRecord.State.MANIFEST_PUBLISHED, 4), + progressedRecord(DurablePublicationRecord.State.REFERENCE_PUBLISHED, 5)); + + for (int currentIndex = 0; currentIndex < normalPath.size(); currentIndex++) { + LocalPersistentControlPlane controlPlane = controlFixture().controlPlane(); + for (int prefixIndex = 0; prefixIndex <= currentIndex; prefixIndex++) { + controlPlane.storeOperation(normalPath.get(prefixIndex)); + } + DurablePublicationRecord current = normalPath.get(currentIndex); + DurablePublicationRecord quarantined = quarantinedFrom(current, current.stateRevision() + 1); + + controlPlane.storeOperation(quarantined); + controlPlane.storeOperation(quarantined); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> + controlPlane.storeOperation( + quarantinedFrom(quarantined, quarantined.stateRevision() + 1))); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(quarantined); + } + } + + @Test + void sealedFactsAndPublishedDigestsFreezeAcrossLaterRevisions() throws IOException { + LocalPersistentControlPlane controlPlane = controlFixture().controlPlane(); + storeSealedOperation(controlPlane); + + for (DurablePublicationRecord changedSealedFact : + List.of( + progressedRecord( + DurablePublicationRecord.State.DATA_PUBLISHED, + 3, + 43, + 1, + 2, + DIGEST_C, + 0, + SEALED_AT, + "", + ""), + progressedRecord( + DurablePublicationRecord.State.DATA_PUBLISHED, + 3, + 42, + 2, + 2, + DIGEST_C, + 0, + SEALED_AT, + "", + ""), + progressedRecord( + DurablePublicationRecord.State.DATA_PUBLISHED, + 3, + 42, + 1, + 3, + DIGEST_C, + 0, + SEALED_AT, + "", + ""), + progressedRecord( + DurablePublicationRecord.State.DATA_PUBLISHED, + 3, + 42, + 1, + 2, + DIGEST_A, + 0, + SEALED_AT, + "", + ""), + progressedRecord( + DurablePublicationRecord.State.DATA_PUBLISHED, + 3, + 42, + 1, + 2, + DIGEST_C, + 1, + SEALED_AT, + "", + ""), + progressedRecord( + DurablePublicationRecord.State.DATA_PUBLISHED, + 3, + 42, + 1, + 2, + DIGEST_C, + 0, + SEALED_AT.plusSeconds(1), + "", + ""))) { + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeOperation(changedSealedFact)); + } + + controlPlane.storeOperation(progressedRecord(DurablePublicationRecord.State.DATA_PUBLISHED, 3)); + controlPlane.storeOperation( + progressedRecord(DurablePublicationRecord.State.MANIFEST_PUBLISHED, 4)); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> + controlPlane.storeOperation( + progressedRecord( + DurablePublicationRecord.State.REFERENCE_PUBLISHED, + 5, + 42, + 1, + 2, + DIGEST_C, + 0, + SEALED_AT, + DIGEST_C, + DIGEST_B))); + controlPlane.storeOperation( + progressedRecord(DurablePublicationRecord.State.REFERENCE_PUBLISHED, 5)); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> + controlPlane.storeOperation( + progressedRecord( + DurablePublicationRecord.State.PUBLISHED, + 6, + 42, + 1, + 2, + DIGEST_C, + 0, + SEALED_AT, + DIGEST_A, + DIGEST_C))); + assertThat(controlPlane.findOperation(OPERATION_ID)) + .contains(progressedRecord(DurablePublicationRecord.State.REFERENCE_PUBLISHED, 5)); + } + + @Test + void manifestAndReferenceAreImmutableIdempotentRecords() throws IOException { + LocalPersistentControlPlane controlPlane = controlFixture().controlPlane(); + PrivateFileManifest manifest = manifest(); + PublishedReferenceRecord reference = referenceRecord(); + + controlPlane.storeManifest(manifest); + controlPlane.storeManifest(manifest); + controlPlane.storeReference(reference); + controlPlane.storeReference(reference); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeManifest(manifestWithSchemaId("worklog-v2"))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeReference(referenceWithVersion("version-02"))); + assertThat(controlPlane.findManifest(FILE_ID)).contains(manifest); + assertThat(controlPlane.findReference(FILE_ID)).contains(reference); + } + + @Test + void forcedWriteFaultPointsAreStrictAndShardExistsBeforeTempForce() throws IOException { + ControlFixture fixture = controlFixture(); + List points = new ArrayList<>(); + Path shard = operationPath(fixture.root(), OPERATION_ID).getParent(); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + if (point == LocalPersistentControlPlane.FaultPoint.TEMP_FORCED) { + assertThat(shard).isDirectory(); + } + points.add(point); + }); + + controlPlane.storeOperation(writingRecord()); + + assertThat(points) + .containsExactly( + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + } + + @Test + void corruptNewerAndMismatchedRecordsAreIntegrityFailuresNeverAbsent() throws IOException { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + controlPlane.storeOperation(writingRecord()); + Path operationPath = operationPath(fixture.root(), OPERATION_ID); + + Files.writeString(operationPath, "{}", StandardCharsets.UTF_8); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + + byte[] newer = + new String( + new FileserverControlRecordCodec().encodeOperation(writingRecord()), + StandardCharsets.UTF_8) + .replace("\"schemaVersion\":2", "\"schemaVersion\":3") + .getBytes(StandardCharsets.UTF_8); + Files.write(operationPath, newer); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + + Files.write( + operationPath, + new FileserverControlRecordCodec() + .encodeOperation( + operationRecord( + 1, + DurablePublicationRecord.State.WRITING, + "other/order:02", + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "other-order-02.part", + CREATED_AT))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.storeOperation(writingRecord())); + + controlPlane.storeManifest(manifest()); + Files.write( + manifestPath(fixture.root(), FILE_ID), + new FileserverControlRecordCodec() + .encodeManifest(manifestFor("f".repeat(32), "worklog-v1"))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.storeManifest(manifest())); + + controlPlane.storeReference(referenceRecord()); + Files.write( + referencePath(fixture.root(), FILE_ID), + new FileserverControlRecordCodec() + .encodeReference(referenceRecordFor("f".repeat(32), "version-01"))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findReference(FILE_ID)); + } + + @Test + void retryAfterReplacementFaultRepairsParentForceWithoutIncompatibleOverwrite() + throws IOException { + ControlFixture fixture = controlFixture(); + List points = new ArrayList<>(); + AtomicBoolean failOnce = new AtomicBoolean(true); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + points.add(point); + if (point == LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED + && failOnce.getAndSet(false)) { + throw new InjectedFault(); + } + }); + + assertThatThrownBy(() -> controlPlane.storeOperation(writingRecord())) + .isExactlyInstanceOf(InjectedFault.class); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.CONFLICT, + () -> controlPlane.storeOperation(writingRecord(1, CREATED_AT.minusSeconds(1)))); + controlPlane.storeOperation(writingRecord()); + + assertThat(points) + .containsExactly( + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + } + + @Test + void parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry() throws IOException { + ControlFixture fixture = controlFixture(); + List points = new ArrayList<>(); + AtomicBoolean failOnce = new AtomicBoolean(true); + InjectedFault original = new InjectedFault(); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + points.add(point); + if (point == LocalPersistentControlPlane.FaultPoint.PARENT_FORCED + && failOnce.getAndSet(false)) { + throw original; + } + }); + + assertThatThrownBy(() -> controlPlane.storeOperation(writingRecord())).isSameAs(original); + controlPlane.storeOperation(writingRecord()); + + assertThat(points) + .containsExactly( + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + } + + @Test + void immutableHardLinkHasFullFaultOrderAndRepairsRecordAndParentFailures() throws IOException { + ControlFixture successfulFixture = controlFixture(); + List successfulPoints = new ArrayList<>(); + LocalPersistentControlPlane successful = successfulFixture.controlPlane(successfulPoints::add); + successful.storeManifest(manifest()); + assertThat(successfulPoints) + .containsExactly( + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(successful.findManifest(FILE_ID)).contains(manifest()); + + ControlFixture recordFixture = controlFixture(); + List recordPoints = new ArrayList<>(); + AtomicBoolean failRecordOnce = new AtomicBoolean(true); + LocalPersistentControlPlane recordFailure = + recordFixture.controlPlane( + point -> { + recordPoints.add(point); + if (point == LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED + && failRecordOnce.getAndSet(false)) { + throw new InjectedFault(); + } + }); + assertThatThrownBy(() -> recordFailure.storeManifest(manifest())) + .isExactlyInstanceOf(InjectedFault.class); + recordFailure.storeManifest(manifest()); + assertThat(recordPoints) + .containsExactly( + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(recordFailure.findManifest(FILE_ID)).contains(manifest()); + + ControlFixture parentFixture = controlFixture(); + List parentPoints = new ArrayList<>(); + AtomicBoolean failParentOnce = new AtomicBoolean(true); + LocalPersistentControlPlane parentFailure = + parentFixture.controlPlane( + point -> { + parentPoints.add(point); + if (point == LocalPersistentControlPlane.FaultPoint.PARENT_FORCED + && failParentOnce.getAndSet(false)) { + throw new InjectedFault(); + } + }); + assertThatThrownBy(() -> parentFailure.storeManifest(manifest())) + .isExactlyInstanceOf(InjectedFault.class); + parentFailure.storeManifest(manifest()); + assertThat(parentPoints) + .containsExactly( + LocalPersistentControlPlane.FaultPoint.TEMP_FORCED, + LocalPersistentControlPlane.FaultPoint.RECORD_REPLACED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED, + LocalPersistentControlPlane.FaultPoint.PARENT_FORCED); + assertThat(parentFailure.findManifest(FILE_ID)).contains(manifest()); + } + + @Test + void tempForceFailureCleansOnlyItsUniqueTemporaryFile() throws IOException { + ControlFixture fixture = controlFixture(); + Path shard = operationPath(fixture.root(), OPERATION_ID).getParent(); + Files.createDirectory( + shard, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path bystander = Files.writeString(shard.resolve(".bystander.tmp"), "keep"); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + if (point == LocalPersistentControlPlane.FaultPoint.TEMP_FORCED) { + throw new InjectedFault(); + } + }); + + assertThatThrownBy(() -> controlPlane.storeOperation(writingRecord())) + .isExactlyInstanceOf(InjectedFault.class); + + assertThat(operationPath(fixture.root(), OPERATION_ID)).doesNotExist(); + assertThat(bystander).hasContent("keep"); + try (var entries = Files.list(shard)) { + assertThat(entries.map(path -> path.getFileName().toString())) + .containsExactlyInAnyOrder( + ".bystander.tmp", + operationLockPath(fixture.root(), OPERATION_ID).getFileName().toString()); + } + } + + @Test + void sameOperationLockSerializesAcrossInstancesAndHasExplicitClosePolicy() throws Exception { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane first = fixture.controlPlane(); + LocalPersistentControlPlane second = fixture.controlPlane(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch attempted = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); + LocalPersistentControlPlane.OperationLock firstLock = first.acquireOperationLock(OPERATION_ID); + try { + Future contender = + executor.submit( + () -> { + attempted.countDown(); + try (LocalPersistentControlPlane.OperationLock ignored = + second.acquireOperationLock(OPERATION_ID)) { + entered.countDown(); + } + }); + + assertThat(attempted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(entered.await(200, TimeUnit.MILLISECONDS)).isFalse(); + firstLock.close(); + firstLock.close(); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + contender.get(5, TimeUnit.SECONDS); + + Path lockFile = operationLockPath(fixture.root(), OPERATION_ID); + assertThat(Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)).isTrue(); + assertThat(Files.getPosixFilePermissions(lockFile)) + .containsExactlyInAnyOrderElementsOf(PosixFilePermissions.fromString("rw-------")); + + LocalPersistentControlPlane.OperationLock ownerLock = + first.acquireOperationLock("customer/order:02"); + try { + Future wrongThreadClose = executor.submit(ownerLock::close); + assertThatThrownBy(() -> wrongThreadClose.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(IllegalStateException.class); + } finally { + ownerLock.close(); + } + } finally { + firstLock.close(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void storeOperationAcquiresTheFullOperationLockInternallyAcrossInstances() throws Exception { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane first = fixture.controlPlane(); + LocalPersistentControlPlane second = fixture.controlPlane(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch attempted = new CountDownLatch(1); + CountDownLatch stored = new CountDownLatch(1); + LocalPersistentControlPlane.OperationLock firstLock = first.acquireOperationLock(OPERATION_ID); + try { + Future store = + executor.submit( + () -> { + attempted.countDown(); + second.storeOperation(writingRecord()); + stored.countDown(); + }); + + assertThat(attempted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(stored.await(200, TimeUnit.MILLISECONDS)).isFalse(); + firstLock.close(); + assertThat(stored.await(5, TimeUnit.SECONDS)).isTrue(); + store.get(5, TimeUnit.SECONDS); + assertThat(first.findOperation(OPERATION_ID)).contains(writingRecord()); + } finally { + firstLock.close(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void heldOperationReentrancyIsScopedToTheAttestedRoot() throws Exception { + ControlFixture rootA = controlFixture(); + ControlFixture rootB = controlFixture(); + LocalPersistentControlPlane firstRoot = rootA.controlPlane(); + LocalPersistentControlPlane secondRoot = rootB.controlPlane(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch rootAHeld = new CountDownLatch(1); + CountDownLatch rootBHeld = new CountDownLatch(1); + CountDownLatch beginCrossRootStore = new CountDownLatch(1); + CountDownLatch releaseRootB = new CountDownLatch(1); + CountDownLatch stored = new CountDownLatch(1); + try { + Future crossRootStore = + executor.submit( + () -> { + try (LocalPersistentControlPlane.OperationLock ignored = + firstRoot.acquireOperationLock(OPERATION_ID)) { + rootAHeld.countDown(); + await(beginCrossRootStore); + secondRoot.storeOperation(writingRecord()); + stored.countDown(); + } + }); + assertThat(rootAHeld.await(5, TimeUnit.SECONDS)).isTrue(); + Future rootBHolder = + executor.submit( + () -> { + try (LocalPersistentControlPlane.OperationLock ignored = + secondRoot.acquireOperationLock(OPERATION_ID)) { + rootBHeld.countDown(); + await(releaseRootB); + } + }); + + assertThat(rootBHeld.await(5, TimeUnit.SECONDS)).isTrue(); + beginCrossRootStore.countDown(); + assertThat(stored.await(200, TimeUnit.MILLISECONDS)).isFalse(); + releaseRootB.countDown(); + assertThat(stored.await(5, TimeUnit.SECONDS)).isTrue(); + rootBHolder.get(5, TimeUnit.SECONDS); + crossRootStore.get(5, TimeUnit.SECONDS); + assertThat(secondRoot.findOperation(OPERATION_ID)).contains(writingRecord()); + } finally { + beginCrossRootStore.countDown(); + releaseRootB.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void operationLockAlsoHoldsTheStableOsFileLock() throws Exception { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + LocalPersistentControlPlane.OperationLock operationLock = + controlPlane.acquireOperationLock(OPERATION_ID); + Path lockPath = operationLockPath(fixture.root(), OPERATION_ID); + try (FileChannel probe = + FileChannel.open(lockPath, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { + FileLock overlapping = null; + try { + overlapping = probe.tryLock(); + } catch (OverlappingFileLockException expected) { + // The held lock is independently visible to the JVM's OS-lock table. + } + assertThat(overlapping).isNull(); + + operationLock.close(); + try (FileLock acquiredAfterClose = probe.tryLock()) { + assertThat(acquiredAfterClose).isNotNull(); + } + } finally { + operationLock.close(); + } + } + + @Test + void operationLockClosePoisonsOnlyTheAttestedRootWhenUnlockCannotBeProven() throws Exception { + ControlFixture poisonedRoot = controlFixture(); + RecordingLockLifecycle lifecycle = new RecordingLockLifecycle(true, true); + LocalPersistentControlPlane poisoned = poisonedRoot.controlPlane(lifecycle); + LocalPersistentControlPlane.OperationLock lock = poisoned.acquireOperationLock(OPERATION_ID); + try { + assertFailureKind(LocalPersistentControlPlane.FailureKind.STORAGE, lock::close); + assertThat(poisoned.operationLockHeldByCurrentThread(OPERATION_ID)).isFalse(); + assertThat(poisoned.operationLockStripeHeldByCurrentThread(OPERATION_ID)).isFalse(); + + lifecycle.allowReleaseAndClose(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + assertFutureFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, + executor.submit( + () -> { + try (LocalPersistentControlPlane.OperationLock ignored = + poisoned.acquireOperationLock(OPERATION_ID)) { + throw new AssertionError("poisoned root unexpectedly acquired a lock"); + } + })); + assertFutureFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, + executor.submit(() -> poisoned.storeOperation(writingRecord()))); + assertFutureFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, + executor.submit( + () -> { + try (LocalPersistentControlPlane.OperationLock ignored = + poisonedRoot.controlPlane().acquireOperationLock(OPERATION_ID)) { + throw new AssertionError("same-root instance ignored the poison latch"); + } + })); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + + ControlFixture otherRoot = controlFixture(); + LocalPersistentControlPlane unaffected = otherRoot.controlPlane(); + unaffected.storeOperation(writingRecord()); + assertThat(unaffected.findOperation(OPERATION_ID)).contains(writingRecord()); + } finally { + lifecycle.forceCleanup(); + } + } + + @Test + void poisonedRootBlocksEveryWriteIncludingHeldLockFastPathButAllowsReads() throws Exception { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane healthy = fixture.controlPlane(); + healthy.storeOperation(writingRecord()); + healthy.storeManifest(manifest()); + healthy.storeReference(referenceRecord()); + + LocalPersistentControlPlane.OperationLock held = healthy.acquireOperationLock(OPERATION_ID); + RecordingLockLifecycle lifecycle = new RecordingLockLifecycle(true, true); + LocalPersistentControlPlane poisoner = fixture.controlPlane(lifecycle); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + assertFutureFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, + executor.submit( + () -> { + try (LocalPersistentControlPlane.OperationLock ignored = + poisoner.acquireOperationLock("customer/order:poison")) { + // The injected close failure poisons this attested root. + } + })); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, + () -> healthy.storeOperation(writingRecord())); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, () -> healthy.storeManifest(manifest())); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, + () -> healthy.storeReference(referenceRecord())); + + assertThat(healthy.findOperation(OPERATION_ID)).contains(writingRecord()); + assertThat(healthy.findManifest(FILE_ID)).contains(manifest()); + assertThat(healthy.findReference(FILE_ID)).contains(referenceRecord()); + } finally { + held.close(); + lifecycle.forceCleanup(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void releaseFailureWithSuccessfulChannelCloseReportsStorageWithoutPoisoning() throws Exception { + ControlFixture fixture = controlFixture(); + RecordingLockLifecycle lifecycle = new RecordingLockLifecycle(true, false); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(lifecycle); + LocalPersistentControlPlane.OperationLock lock = + controlPlane.acquireOperationLock(OPERATION_ID); + + assertFailureKind(LocalPersistentControlPlane.FailureKind.STORAGE, lock::close); + assertThat(controlPlane.operationLockHeldByCurrentThread(OPERATION_ID)).isFalse(); + assertThat(controlPlane.operationLockStripeHeldByCurrentThread(OPERATION_ID)).isFalse(); + + lifecycle.allowReleaseAndClose(); + assertOperationLockCanBeAcquiredByAnotherThread(controlPlane); + controlPlane.storeOperation(writingRecord()); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + lifecycle.forceCleanup(); + } + + @Test + void successfulReleaseWithChannelCloseFailureReportsStorageWithoutPoisoning() throws Exception { + ControlFixture fixture = controlFixture(); + RecordingLockLifecycle lifecycle = new RecordingLockLifecycle(false, true); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(lifecycle); + LocalPersistentControlPlane.OperationLock lock = + controlPlane.acquireOperationLock(OPERATION_ID); + try { + assertFailureKind(LocalPersistentControlPlane.FailureKind.STORAGE, lock::close); + assertThat(controlPlane.operationLockHeldByCurrentThread(OPERATION_ID)).isFalse(); + assertThat(controlPlane.operationLockStripeHeldByCurrentThread(OPERATION_ID)).isFalse(); + + lifecycle.allowReleaseAndClose(); + assertOperationLockCanBeAcquiredByAnotherThread(controlPlane); + controlPlane.storeOperation(writingRecord()); + assertThat(controlPlane.findOperation(OPERATION_ID)).contains(writingRecord()); + } finally { + lifecycle.forceCleanup(); + } + } + + @Test + void symlinkWrongTypeWrongModeAndOversizedRecordsFailAsIntegrityNeverAbsent() throws IOException { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + Path operation = operationPath(fixture.root(), OPERATION_ID); + Path shard = operation.getParent(); + Path outside = Files.createDirectory(tempDirectory.resolve("outside")); + + Files.createSymbolicLink(shard, outside); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + assertThat(outside).isEmptyDirectory(); + Files.delete(shard); + + Files.writeString(shard, "not-a-directory"); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + Files.delete(shard); + + Files.createDirectory( + shard, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---"))); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + Files.setPosixFilePermissions(shard, PosixFilePermissions.fromString("rwx------")); + + Path externalRecord = Files.writeString(outside.resolve("external.json"), "external-secret"); + Files.createSymbolicLink(operation, externalRecord); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + assertThat(externalRecord).hasContent("external-secret"); + Files.delete(operation); + + Files.write(operation, new byte[16_385]); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.findOperation(OPERATION_ID)); + } + + @Test + void recordIoUsesInjectedSecureShardRelativeOperationsForReadCreateStatAndDelete() + throws IOException { + ControlFixture fixture = controlFixture(); + RecordingSecureRecordOperations operations = new RecordingSecureRecordOperations(); + LocalPersistentControlPlane successful = + fixture.controlPlane(point -> {}, operations, fixture::verifyIdentity); + + successful.storeOperation(writingRecord()); + assertThat(successful.findOperation(OPERATION_ID)).contains(writingRecord()); + + LocalPersistentControlPlane failing = + fixture.controlPlane( + point -> { + if (point == LocalPersistentControlPlane.FaultPoint.TEMP_FORCED) { + throw new InjectedFault(); + } + }, + operations, + fixture::verifyIdentity); + assertThatThrownBy(() -> failing.storeOperation(sealedRecord(2))) + .isExactlyInstanceOf(InjectedFault.class); + + assertThat(operations.actions) + .contains( + LocalPersistentControlPlane.SecureRecordAction.STAT_SHARD_NOFOLLOW, + LocalPersistentControlPlane.SecureRecordAction.READ, + LocalPersistentControlPlane.SecureRecordAction.CREATE_NEW_AND_FORCE, + LocalPersistentControlPlane.SecureRecordAction.STAT_NOFOLLOW, + LocalPersistentControlPlane.SecureRecordAction.DELETE_EXACT); + assertThat(operations.calls) + .allSatisfy( + call -> { + assertThat(call.topDirectory()) + .isEqualTo(fixture.root().resolve(".ca-fileserver/operations")); + assertThat(call.shard()).matches("[0-9a-f]{2}"); + assertThat(Path.of(call.relativeFileName())).isRelative(); + assertThat(Path.of(call.relativeFileName()).getNameCount()).isEqualTo(1); + }); + assertThat(operations.shardCalls) + .allSatisfy( + call -> { + assertThat(call.topDirectory()) + .isEqualTo(fixture.root().resolve(".ca-fileserver/operations")); + assertThat(call.shard()).matches("[0-9a-f]{2}"); + }); + } + + @Test + void cleanupPreservesAReplacementWhoseNoFollowFileKeyDiffersFromCreatedTemp() throws IOException { + ControlFixture fixture = controlFixture(); + AtomicBoolean replaced = new AtomicBoolean(); + InjectedFault original = new InjectedFault(); + Path replacementSource = + Files.writeString(tempDirectory.resolve("replacement-source.tmp"), "replacement"); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + if (point == LocalPersistentControlPlane.FaultPoint.TEMP_FORCED) { + Path shard = operationPath(fixture.root(), OPERATION_ID).getParent(); + try (var paths = Files.list(shard)) { + Path temporary = + paths + .filter(path -> path.getFileName().toString().endsWith(".tmp")) + .findFirst() + .orElseThrow(); + Files.delete(temporary); + Files.move(replacementSource, temporary); + replaced.set(true); + } catch (IOException exception) { + throw new AssertionError(exception); + } + throw original; + } + }); + + assertThatThrownBy(() -> controlPlane.storeOperation(writingRecord())).isSameAs(original); + + assertThat(replaced).isTrue(); + Path shard = operationPath(fixture.root(), OPERATION_ID).getParent(); + try (var paths = Files.list(shard)) { + Path replacement = + paths + .filter(path -> path.getFileName().toString().endsWith(".tmp")) + .findFirst() + .orElseThrow(); + assertThat(replacement).hasContent("replacement"); + } + } + + @Test + void operationRejectsAndPreservesTempReplacementBeforeAtomicMove() throws IOException { + ControlFixture fixture = controlFixture(); + Path replacementSource = + Files.writeString(tempDirectory.resolve("operation-replacement.tmp"), "replacement"); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + if (point == LocalPersistentControlPlane.FaultPoint.TEMP_FORCED) { + replaceForcedTemporary( + operationPath(fixture.root(), OPERATION_ID).getParent(), replacementSource); + } + }); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.storeOperation(writingRecord())); + + assertThat(operationPath(fixture.root(), OPERATION_ID)).doesNotExist(); + assertThat(forcedTemporary(operationPath(fixture.root(), OPERATION_ID).getParent())) + .hasContent("replacement"); + } + + @Test + void immutableRejectsAndPreservesTempReplacementBeforeHardLink() throws IOException { + ControlFixture fixture = controlFixture(); + Path replacementSource = + Files.writeString(tempDirectory.resolve("manifest-replacement.tmp"), "replacement"); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> { + if (point == LocalPersistentControlPlane.FaultPoint.TEMP_FORCED) { + replaceForcedTemporary( + manifestPath(fixture.root(), FILE_ID).getParent(), replacementSource); + } + }); + + assertFailureKind( + LocalPersistentControlPlane.FailureKind.INTEGRITY, + () -> controlPlane.storeManifest(manifest())); + + assertThat(manifestPath(fixture.root(), FILE_ID)).doesNotExist(); + assertThat(forcedTemporary(manifestPath(fixture.root(), FILE_ID).getParent())) + .hasContent("replacement"); + } + + @Test + void shardCreationAndAbsoluteCommitAreBracketedByIdentityVerification() throws IOException { + ControlFixture fixture = controlFixture(); + AtomicInteger identityChecks = new AtomicInteger(); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + point -> {}, + LocalPersistentControlPlane.systemSecureRecordOperations(), + () -> { + fixture.verifyIdentity(); + identityChecks.incrementAndGet(); + }); + + identityChecks.set(0); + controlPlane.storeManifest(manifest()); + + assertThat(identityChecks).hasValueGreaterThanOrEqualTo(5); + Path shard = manifestPath(fixture.root(), FILE_ID).getParent(); + assertThat(Files.getPosixFilePermissions(shard)) + .containsExactlyInAnyOrderElementsOf(PosixFilePermissions.fromString("rwx------")); + assertThat(Files.getOwner(shard).getName()).isEqualTo(fixture.evidence().expectedOwner()); + assertThat(Files.getFileStore(shard).name()).isEqualTo(fixture.evidence().fileStoreName()); + + ControlFixture changedFixture = controlFixture(); + Path target = manifestPath(changedFixture.root(), FILE_ID); + LocalPersistentControlPlane changed = + changedFixture.controlPlane( + point -> {}, + LocalPersistentControlPlane.systemSecureRecordOperations(), + () -> { + changedFixture.verifyIdentity(); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new InjectedIdentityChange(); + } + }); + assertFailureKind( + LocalPersistentControlPlane.FailureKind.STORAGE, () -> changed.storeManifest(manifest())); + } + + @Test + void concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage() throws Exception { + ControlFixture fixture = controlFixture(); + LocalPersistentControlPlane first = fixture.controlPlane(); + LocalPersistentControlPlane second = fixture.controlPlane(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future one = + executor.submit( + () -> { + ready.countDown(); + await(start); + first.storeManifest(manifest()); + }); + Future two = + executor.submit( + () -> { + ready.countDown(); + await(start); + second.storeManifest(manifest()); + }); + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + one.get(5, TimeUnit.SECONDS); + two.get(5, TimeUnit.SECONDS); + assertThat(first.findManifest(FILE_ID)).contains(manifest()); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private ControlFixture controlFixture() throws IOException { + Path root = createPersistentRoot(); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + return new ControlFixture(root, attestor, attestor.attest(destinationFor(root))); + } + + private static LocalPublicationJournalRecord publishedR1Record(String operationId) { + return LocalPublicationJournalRecord.sealed( + operationId, DIGEST_A, "report.csv", "customer-order-01.part", 42, 1, 2, DIGEST_C, 0) + .published(PUBLISHED_AT, PublicationGuarantee.UNIQUE_ATOMIC_CREATE.name()); + } + + private static void writeR1Operation(Path root, String operationId, byte[] encoded) + throws IOException { + Path target = operationPath(root, operationId); + Files.createDirectory( + target.getParent(), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Files.write(target, encoded); + } + + private static LocalPersistentControlPlane.FaultContext operationFault( + LocalPersistentControlPlane.FaultPoint boundary) { + return new LocalPersistentControlPlane.FaultContext( + LocalPersistentControlPlane.ControlRecordKind.OPERATION, + OPERATION_ID, + Optional.of( + new LocalPersistentControlPlane.OperationFaultState( + DurablePublicationRecord.State.WRITING, 1)), + boundary); + } + + private static LocalPersistentControlPlane.FaultContext immutableFault( + LocalPersistentControlPlane.ControlRecordKind kind, + LocalPersistentControlPlane.FaultPoint boundary) { + return new LocalPersistentControlPlane.FaultContext(kind, FILE_ID, Optional.empty(), boundary); + } + + private Path createPersistentRoot() throws IOException { + Path root = + tempDirectory + .resolve("persistent-root-" + rootSequence.incrementAndGet()) + .toAbsolutePath() + .normalize(); + Files.createDirectory( + root, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Files.createFile( + root.resolve(SENTINEL_NAME), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + Files.write(root.resolve(SENTINEL_NAME), SENTINEL_CONTENT); + return root; + } + + private static CompiledFileDestination destinationFor(Path root) throws IOException { + PosixFileAttributes attributes = Files.readAttributes(root, PosixFileAttributes.class); + FileStore store = Files.getFileStore(root); + Set permissions = Set.copyOf(attributes.permissions()); + return new CompiledFileDestination( + new FileDestinationId("local-export"), + "local-primary", + root, + 1_000, + 1_048_576, + store.name(), + store.type(), + SENTINEL_NAME, + sha256(root.resolve(SENTINEL_NAME)), + attributes.owner().getName(), + mode(permissions), + permissions, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + } + + private static DurablePublicationRecord writingRecord() { + return writingRecord(1); + } + + private static DurablePublicationRecord writingRecord(long revision) { + return writingRecord(revision, CREATED_AT); + } + + private static DurablePublicationRecord writingRecord(long revision, Instant createdAt) { + return operationRecord( + revision, + DurablePublicationRecord.State.WRITING, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + createdAt); + } + + private static DurablePublicationRecord storeSealedOperation( + LocalPersistentControlPlane controlPlane) { + DurablePublicationRecord sealed = sealedRecord(2); + controlPlane.storeOperation(writingRecord()); + controlPlane.storeOperation(sealed); + return sealed; + } + + private static DurablePublicationRecord sealedRecord(long revision) { + return operationRecord( + revision, + DurablePublicationRecord.State.SEALED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + CREATED_AT); + } + + private static DurablePublicationRecord progressedRecord( + DurablePublicationRecord.State state, long revision) { + String manifestDigest = + switch (state) { + case MANIFEST_PUBLISHED, REFERENCE_PUBLISHED, PUBLISHED -> DIGEST_A; + default -> ""; + }; + String referenceDigest = + switch (state) { + case REFERENCE_PUBLISHED, PUBLISHED -> DIGEST_B; + default -> ""; + }; + return progressedRecord( + state, revision, 42, 1, 2, DIGEST_C, 0, SEALED_AT, manifestDigest, referenceDigest); + } + + private static DurablePublicationRecord progressedRecord( + DurablePublicationRecord.State state, + long revision, + long byteSize, + long rowCount, + int columnCount, + String sha256, + long formulaMitigatedCount, + Instant sealedAt, + String manifestDigest, + String referenceDigest) { + Instant publishedAt = state == DurablePublicationRecord.State.PUBLISHED ? PUBLISHED_AT : null; + String receiptSnapshot = + state == DurablePublicationRecord.State.PUBLISHED + ? new FileserverControlRecordCodec() + .encodeReceiptSnapshot( + receipt(byteSize, rowCount, columnCount, sha256, formulaMitigatedCount)) + : ""; + return new DurablePublicationRecord( + 2, + revision, + state, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + byteSize, + rowCount, + columnCount, + sha256, + formulaMitigatedCount, + manifestDigest, + referenceDigest, + CREATED_AT, + sealedAt, + publishedAt, + "", + receiptSnapshot); + } + + private static DurablePublicationRecord quarantinedRecord(long revision) { + return new DurablePublicationRecord( + 2, + revision, + DurablePublicationRecord.State.QUARANTINED, + OPERATION_ID, + DIGEST_A, + "policy-v1", + DIGEST_B, + "local-export", + "local-primary", + FILE_ID, + "routea1", + "report.csv", + "customer-order-01.part", + 42, + 1, + 2, + DIGEST_C, + 0, + DIGEST_A, + DIGEST_B, + CREATED_AT, + SEALED_AT, + null, + "INTEGRITY_FAILURE", + ""); + } + + private static DurablePublicationRecord quarantinedFrom( + DurablePublicationRecord current, long revision) { + return new DurablePublicationRecord( + 2, + revision, + DurablePublicationRecord.State.QUARANTINED, + current.operationId(), + current.requestFingerprint(), + current.effectivePolicyRevision(), + current.effectivePolicyDigest(), + current.destinationId(), + current.providerId(), + current.fileId(), + current.routeToken(), + current.publishedFileName(), + current.stageFileName(), + current.byteSize(), + current.rowCount(), + current.columnCount(), + current.sha256(), + current.formulaMitigatedCount(), + current.manifestDigest(), + current.referenceDigest(), + current.createdAt(), + current.sealedAt(), + null, + "INTEGRITY_FAILURE", + ""); + } + + private static FilePublishReceipt receipt( + long byteSize, long rowCount, int columnCount, String sha256, long formulaMitigatedCount) { + return new FilePublishReceipt( + new FilePublishOperationId(OPERATION_ID), + new R2PublishedReferenceCodec().encode("routea1", FILE_ID), + new FileDestinationId("local-export"), + "report.csv", + new FileVersion("version-01"), + "csv-rfc4180", + "text/csv", + "UTF-8", + byteSize, + rowCount, + columnCount, + sha256, + PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + formulaMitigatedCount); + } + + private static DurablePublicationRecord operationRecord( + long revision, + DurablePublicationRecord.State state, + String operationId, + String requestFingerprint, + String policyRevision, + String policyDigest, + String destinationId, + String providerId, + String fileId, + String routeToken, + String publishedFileName, + String stageFileName, + Instant createdAt) { + boolean writing = state == DurablePublicationRecord.State.WRITING; + return new DurablePublicationRecord( + 2, + revision, + state, + operationId, + requestFingerprint, + policyRevision, + policyDigest, + destinationId, + providerId, + fileId, + routeToken, + publishedFileName, + stageFileName, + writing ? 0 : 42, + writing ? 0 : 1, + writing ? 0 : 2, + writing ? "" : DIGEST_C, + 0, + "", + "", + createdAt, + writing ? null : SEALED_AT, + null, + "", + ""); + } + + private static PrivateFileManifest manifest() { + return manifestWithSchemaId("worklog-v1"); + } + + private static PrivateFileManifest manifestWithSchemaId(String schemaId) { + return manifestFor(FILE_ID, schemaId); + } + + private static PrivateFileManifest manifestFor(String fileId, String schemaId) { + return new PrivateFileManifest( + 1, + OPERATION_ID, + fileId, + "local-primary", + new R2PublishedReferenceCodec().encode("routea1", fileId).value(), + DIGEST_A, + "local-export", + schemaId, + 1, + DIGEST_B, + "csv-rfc4180", + DIGEST_A, + "policy-v1", + DIGEST_B, + "report.csv", + "version-01", + "text/csv", + "UTF-8", + 42, + 1, + 2, + DIGEST_C, + 0, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + fileId + ".csv", + CREATED_AT, + PUBLISHED_AT); + } + + private static PublishedReferenceRecord referenceRecord() { + return referenceRecordFor(FILE_ID, "version-01"); + } + + private static PublishedReferenceRecord referenceWithVersion(String version) { + return referenceRecordFor(FILE_ID, version); + } + + private static PublishedReferenceRecord referenceRecordFor(String fileId, String version) { + return new PublishedReferenceRecord( + 1, + fileId, + "routea1", + new R2PublishedReferenceCodec().encode("routea1", fileId).value(), + OPERATION_ID, + version, + DIGEST_A, + fileId + ".csv", + "local-export", + "local-primary", + "report.csv", + "text/csv", + "UTF-8", + 42, + DIGEST_C, + PUBLISHED_AT); + } + + private static Path operationPath(Path root, String operationId) { + String token = sha256(operationId.getBytes(StandardCharsets.UTF_8)); + return root.resolve(".ca-fileserver/operations") + .resolve(token.substring(0, 2)) + .resolve(token + ".json"); + } + + private static Path operationLockPath(Path root, String operationId) { + String token = sha256(operationId.getBytes(StandardCharsets.UTF_8)); + return root.resolve(".ca-fileserver/operations") + .resolve(token.substring(0, 2)) + .resolve(token + ".lock"); + } + + private static Path manifestPath(Path root, String fileId) { + return root.resolve(".ca-fileserver/manifests") + .resolve(fileId.substring(0, 2)) + .resolve(fileId + ".json"); + } + + private static Path referencePath(Path root, String fileId) { + return root.resolve(".ca-fileserver/references") + .resolve(fileId.substring(0, 2)) + .resolve(fileId + ".json"); + } + + private static void assertFailureKind( + LocalPersistentControlPlane.FailureKind expected, Runnable invocation) { + assertThatThrownBy(invocation::run) + .isInstanceOf(LocalPersistentControlPlane.LocalPersistentControlPlaneException.class) + .satisfies( + failure -> + assertThat( + ((LocalPersistentControlPlane.LocalPersistentControlPlaneException) failure) + .kind()) + .isEqualTo(expected)); + } + + private static void assertFutureFailureKind( + LocalPersistentControlPlane.FailureKind expected, Future future) { + assertThatThrownBy(() -> future.get(1, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .satisfies( + failure -> + assertThat( + ((LocalPersistentControlPlane.LocalPersistentControlPlaneException) + failure.getCause()) + .kind()) + .isEqualTo(expected)); + } + + private static void assertOperationLockCanBeAcquiredByAnotherThread( + LocalPersistentControlPlane controlPlane) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future acquired = + executor.submit( + () -> { + try (LocalPersistentControlPlane.OperationLock ignored = + controlPlane.acquireOperationLock(OPERATION_ID)) { + // Acquisition itself proves both the JVM stripe and OS lock were released. + } + }); + acquired.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private static String sha256(Path file) throws IOException { + return sha256(Files.readAllBytes(file)); + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 must be available", exception); + } + } + + private static String mode(Set permissions) { + int value = 0; + value |= permissions.contains(PosixFilePermission.OWNER_READ) ? 0400 : 0; + value |= permissions.contains(PosixFilePermission.OWNER_WRITE) ? 0200 : 0; + value |= permissions.contains(PosixFilePermission.OWNER_EXECUTE) ? 0100 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_READ) ? 0040 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_WRITE) ? 0020 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_EXECUTE) ? 0010 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_READ) ? 0004 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_WRITE) ? 0002 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_EXECUTE) ? 0001 : 0; + return String.format("%04o", value); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting for concurrent test start"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + + private static void replaceForcedTemporary(Path shard, Path replacementSource) { + Path temporary = forcedTemporary(shard); + try { + Files.delete(temporary); + Files.move(replacementSource, temporary); + } catch (IOException exception) { + throw new AssertionError(exception); + } + } + + private static Path forcedTemporary(Path shard) { + try (var paths = Files.list(shard)) { + return paths + .filter(path -> path.getFileName().toString().endsWith(".tmp")) + .findFirst() + .orElseThrow(); + } catch (IOException exception) { + throw new AssertionError(exception); + } + } + + private record ControlFixture( + Path root, LocalPersistentRootAttestor attestor, LocalPersistentRootEvidence evidence) { + + private LocalPersistentControlPlane controlPlane() { + return new LocalPersistentControlPlane(attestor, evidence); + } + + private LocalPersistentControlPlane controlPlane( + LocalPersistentControlPlane.FaultCallback faultCallback) { + return new LocalPersistentControlPlane(attestor, evidence, faultCallback); + } + + private LocalPersistentControlPlane contextualControlPlane( + LocalPersistentControlPlane.ContextualFaultCallback faultCallback) { + return LocalPersistentControlPlane.withContextualFaultCallback( + attestor, evidence, faultCallback); + } + + private LocalPersistentControlPlane controlPlane( + LocalPersistentControlPlane.FaultCallback faultCallback, + LocalPersistentControlPlane.SecureRecordOperations operations, + LocalPersistentControlPlane.IdentityVerifier identityVerifier) { + return new LocalPersistentControlPlane( + attestor, evidence, faultCallback, operations, identityVerifier); + } + + private LocalPersistentControlPlane controlPlane( + LocalPersistentControlPlane.LockLifecycle lifecycle) { + return new LocalPersistentControlPlane( + attestor, + evidence, + point -> {}, + LocalPersistentControlPlane.systemSecureRecordOperations(), + null, + lifecycle); + } + + private void verifyIdentity() { + attestor.verifyIdentity(evidence); + } + } + + private static final class RecordingLockLifecycle + implements LocalPersistentControlPlane.LockLifecycle { + + private final List locks = new ArrayList<>(); + private final List channels = new ArrayList<>(); + private volatile boolean failRelease; + private volatile boolean failClose; + + private RecordingLockLifecycle(boolean failRelease, boolean failClose) { + this.failRelease = failRelease; + this.failClose = failClose; + } + + @Override + public synchronized void release(FileLock lock) throws IOException { + locks.add(lock); + if (failRelease) { + throw new IOException("injected operation lock release failure"); + } + lock.release(); + } + + @Override + public synchronized void close(FileChannel channel) throws IOException { + channels.add(channel); + if (failClose) { + throw new IOException("injected operation lock channel close failure"); + } + channel.close(); + } + + private void allowReleaseAndClose() { + failRelease = false; + failClose = false; + } + + private synchronized void forceCleanup() { + for (FileLock lock : locks) { + try { + if (lock.isValid()) { + lock.release(); + } + } catch (IOException ignored) { + // Best-effort cleanup of the deliberately leaked test resource. + } + } + for (FileChannel channel : channels) { + try { + if (channel.isOpen()) { + channel.close(); + } + } catch (IOException ignored) { + // Best-effort cleanup of the deliberately leaked test resource. + } + } + } + } + + private static final class RecordingSecureRecordOperations + implements LocalPersistentControlPlane.SecureRecordOperations { + + private final LocalPersistentControlPlane.SecureRecordOperations delegate = + LocalPersistentControlPlane.systemSecureRecordOperations(); + private final List actions = new ArrayList<>(); + private final List calls = new ArrayList<>(); + private final List shardCalls = new ArrayList<>(); + + @Override + public Optional statShard( + Path topDirectory, String shard) throws IOException { + actions.add(LocalPersistentControlPlane.SecureRecordAction.STAT_SHARD_NOFOLLOW); + shardCalls.add(new ShardCall(topDirectory, shard)); + return delegate.statShard(topDirectory, shard); + } + + @Override + public Optional read( + Path topDirectory, String shard, String relativeFileName, int maximumBytes) + throws IOException { + record( + LocalPersistentControlPlane.SecureRecordAction.READ, + topDirectory, + shard, + relativeFileName); + return delegate.read(topDirectory, shard, relativeFileName, maximumBytes); + } + + @Override + public LocalPersistentControlPlane.CreatedTemporary createNewAndForce( + Path topDirectory, String shard, String relativeFileName, byte[] bytes) throws IOException { + record( + LocalPersistentControlPlane.SecureRecordAction.CREATE_NEW_AND_FORCE, + topDirectory, + shard, + relativeFileName); + return delegate.createNewAndForce(topDirectory, shard, relativeFileName, bytes); + } + + @Override + public Optional fileKeyNoFollow( + Path topDirectory, String shard, String relativeFileName) throws IOException { + record( + LocalPersistentControlPlane.SecureRecordAction.STAT_NOFOLLOW, + topDirectory, + shard, + relativeFileName); + return delegate.fileKeyNoFollow(topDirectory, shard, relativeFileName); + } + + @Override + public void deleteExact(Path topDirectory, String shard, String relativeFileName) + throws IOException { + record( + LocalPersistentControlPlane.SecureRecordAction.DELETE_EXACT, + topDirectory, + shard, + relativeFileName); + delegate.deleteExact(topDirectory, shard, relativeFileName); + } + + private void record( + LocalPersistentControlPlane.SecureRecordAction action, + Path topDirectory, + String shard, + String relativeFileName) { + actions.add(action); + calls.add( + new LocalPersistentControlPlane.SecureRecordCall(topDirectory, shard, relativeFileName)); + } + } + + private record ShardCall(Path topDirectory, String shard) {} + + private static final class InjectedFault extends RuntimeException {} + + private static final class InjectedIdentityChange extends RuntimeException {} +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java new file mode 100644 index 0000000..1b22d27 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java @@ -0,0 +1,229 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.filepublication.FilePublishRequest; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class LocalPersistentCrashRecoveryTest { + + private static final long PROCESS_TIMEOUT_SECONDS = 20; + private static final long PROTOCOL_TIMEOUT_SECONDS = 10; + private static final long OUTPUT_POLL_MILLIS = 20; + private static final int MAXIMUM_CHILD_OUTPUT_BYTES = 4_096; + private static final String CHILD_MAIN = FileserverCrashScenarioMain.class.getName(); + + @TempDir Path temporaryDirectory; + + @ParameterizedTest(name = "{0}") + @EnumSource(FileserverCrashScenarioMain.CrashBoundary.class) + void everyForceBoundaryRecoversWithoutProducerReplayOrPartialFinalBytes( + FileserverCrashScenarioMain.CrashBoundary boundary) throws Exception { + Path root = temporaryDirectory.resolve("crash-" + boundary.name().toLowerCase(Locale.ROOT)); + + ChildResult crashed = runChild("CRASH", root, boundary.name()); + + assertThat(crashed.exitCode()).isEqualTo(FileserverCrashScenarioMain.CRASH_EXIT_CODE); + assertThat(crashed.output()).isEmpty(); + + ChildResult recovered = runChild("RECOVER", root); + String expected = + boundary.permitsIndeterminateQuarantine() + ? FileserverCrashScenarioMain.RECOVERY_INDETERMINATE_QUARANTINED + : FileserverCrashScenarioMain.RECOVERY_SUCCESS; + + assertThat(recovered.exitCode()).isZero(); + assertThat(recovered.output()).isEqualTo(expected); + } + + @Test + void osOperationLockExcludesAnotherProcessAndReleasesAfterCloseAndForcedTermination() + throws Exception { + verifyLockRelease(false); + verifyLockRelease(true); + } + + private void verifyLockRelease(boolean terminateForcibly) throws Exception { + Path root = + temporaryDirectory.resolve(terminateForcibly ? "lock-forced-termination" : "lock-release"); + LocalPersistentPublicationTestFixture.create(root); + StartedChild holder = startChild("LOCK_HOLD", root); + try { + awaitMarker(holder, FileserverCrashScenarioMain.LOCK_ACQUIRED); + assertThat(holder.process().isAlive()).isTrue(); + + ChildResult excluded = runChild("LOCK_TRY", root); + assertThat(excluded.exitCode()).isZero(); + assertThat(excluded.output()).isEqualTo(FileserverCrashScenarioMain.LOCK_BUSY); + + if (terminateForcibly) { + holder.process().destroyForcibly(); + assertThat(holder.process().waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } else { + holder.process().getOutputStream().write("RELEASE\n".getBytes(UTF_8)); + holder.process().getOutputStream().flush(); + awaitMarker(holder, FileserverCrashScenarioMain.LOCK_RELEASED); + assertThat(holder.process().waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + assertThat(holder.process().exitValue()).isZero(); + } + + ChildResult acquiredAfterOwnerExit = runChild("LOCK_TRY", root); + assertThat(acquiredAfterOwnerExit.exitCode()).isZero(); + assertThat(acquiredAfterOwnerExit.output()) + .isEqualTo(FileserverCrashScenarioMain.LOCK_ACQUIRED); + } finally { + if (holder.process().isAlive()) { + holder.process().destroyForcibly(); + holder.process().waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + Files.deleteIfExists(holder.capture()); + } + } + + private static ChildResult runChild(String mode, Path root, String... trailingArguments) + throws IOException, InterruptedException { + StartedChild child = startChild(mode, root, trailingArguments); + try { + boolean finished = child.process().waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + child.process().destroyForcibly(); + child.process().waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS); + throw new AssertionError("forked Fileserver scenario exceeded its bounded timeout"); + } + return new ChildResult(child.process().exitValue(), readProtocolOutput(child.capture())); + } finally { + if (child.process().isAlive()) { + child.process().destroyForcibly(); + } + Files.deleteIfExists(child.capture()); + } + } + + private static StartedChild startChild(String mode, Path root, String... trailingArguments) + throws IOException { + List command = new java.util.ArrayList<>(); + command.add(javaExecutable()); + command.add("-cp"); + command.add(testRuntimeClasspath()); + command.add(CHILD_MAIN); + command.add(mode); + command.add(root.toAbsolutePath().normalize().toString()); + command.addAll(List.of(trailingArguments)); + Path capture = Files.createTempFile(root.getParent(), ".fileserver-child-output-", ".log"); + try { + Process process = + new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(capture.toFile()) + .start(); + return new StartedChild(process, capture); + } catch (IOException failure) { + try { + Files.deleteIfExists(capture); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + private static void awaitMarker(StartedChild child, String marker) + throws IOException, InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(PROTOCOL_TIMEOUT_SECONDS); + while (System.nanoTime() < deadline) { + String output = readProtocolOutput(child.capture()); + if (output.lines().anyMatch(marker::equals)) { + return; + } + if (!child.process().isAlive()) { + throw new AssertionError("forked lock holder exited before its protocol marker"); + } + Thread.sleep(OUTPUT_POLL_MILLIS); + } + throw new AssertionError("forked lock holder exceeded its bounded marker timeout"); + } + + private static String readProtocolOutput(Path capture) throws IOException { + byte[] bytes; + try (InputStream input = Files.newInputStream(capture)) { + bytes = input.readNBytes(MAXIMUM_CHILD_OUTPUT_BYTES + 1); + } + if (bytes.length > MAXIMUM_CHILD_OUTPUT_BYTES) { + return "CHILD_OUTPUT_LIMIT_EXCEEDED"; + } + String output = new String(bytes, UTF_8).trim(); + if (output.isEmpty() || output.lines().allMatch(line -> line.matches("[A-Z][A-Z0-9_]*"))) { + return output; + } + return "NON_PROTOCOL_CHILD_OUTPUT"; + } + + private static String javaExecutable() { + return Path.of( + System.getProperty("java.home"), + "bin", + System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java") + .toString(); + } + + private static String testRuntimeClasspath() { + Set entries = new LinkedHashSet<>(); + addCodeSource(entries, FileserverCrashScenarioMain.class); + addCodeSource(entries, LocalPersistentPublicationProvider.class); + addCodeSource(entries, FilePublishRequest.class); + StringTokenizer classPath = + new StringTokenizer(System.getProperty("java.class.path", ""), java.io.File.pathSeparator); + while (classPath.hasMoreTokens()) { + entries.add(classPath.nextToken()); + } + for (ClassLoader loader = Thread.currentThread().getContextClassLoader(); + loader != null; + loader = loader.getParent()) { + if (loader instanceof URLClassLoader urlClassLoader) { + for (URL url : urlClassLoader.getURLs()) { + if ("file".equals(url.getProtocol())) { + try { + entries.add(Path.of(url.toURI()).toString()); + } catch (URISyntaxException failure) { + throw new IllegalStateException("test runtime classpath URL is invalid", failure); + } + } + } + } + } + if (entries.isEmpty()) { + throw new IllegalStateException("test runtime classpath cannot be resolved"); + } + return String.join(java.io.File.pathSeparator, entries); + } + + private static void addCodeSource(Set entries, Class type) { + try { + entries.add( + Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString()); + } catch (URISyntaxException failure) { + throw new IllegalStateException("class code source is invalid", failure); + } + } + + private record ChildResult(int exitCode, String output) {} + + private record StartedChild(Process process, Path capture) {} +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java new file mode 100644 index 0000000..699b6db --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java @@ -0,0 +1,757 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalPersistentPayloadOperationsTest { + + private static final String OPERATION_ID = "customer/order:01"; + private static final String FILE_ID = "00112233445566778899aabbccddeeff"; + private static final String SENTINEL_NAME = ".ca-fileserver-volume"; + private static final byte[] SENTINEL_CONTENT = "fileserver-r2-payload-root\n".getBytes(UTF_8); + + @TempDir Path temporaryDirectory; + private final AtomicInteger rootSequence = new AtomicInteger(); + + @Test + void stagesThroughOneStreamingWriterThenForcesAndReportsStableDigest() throws IOException { + PayloadFixture fixture = payloadFixture(); + AtomicInteger writerCalls = new AtomicInteger(); + List events = new ArrayList<>(); + LocalPersistentPayloadOperations payload = fixture.payload(events::add); + String stageFileName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + byte[] chunk = "0123456789abcdef".repeat(64).getBytes(UTF_8); + + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage( + OPERATION_ID, + stageFileName, + chunk.length * 128L, + output -> { + writerCalls.incrementAndGet(); + assertThat(output).isNotInstanceOf(java.io.ByteArrayOutputStream.class); + for (int index = 0; index < 128; index++) { + output.write(chunk); + } + }); + + byte[] expected = new byte[chunk.length * 128]; + for (int index = 0; index < 128; index++) { + System.arraycopy(chunk, 0, expected, index * chunk.length, chunk.length); + } + Path stagedPath = stagePath(fixture.root(), OPERATION_ID, stageFileName); + assertThat(writerCalls).hasValue(1); + assertThat(staged.byteSize()).isEqualTo(expected.length); + assertThat(staged.sha256()).isEqualTo(sha256(expected)); + assertThat(Files.readAllBytes(stagedPath)).isEqualTo(expected); + assertThat(Files.getPosixFilePermissions(stagedPath)) + .containsExactlyInAnyOrderElementsOf(PosixFilePermissions.fromString("rw-------")); + assertThat(events) + .containsExactly( + new LocalPersistentPayloadOperations.FaultContext( + LocalPersistentPayloadOperations.FaultPoint.STAGE_FORCED, + OPERATION_ID, + null, + staged)); + } + + @Test + void enforcesTheByteLimitWhileWritingAndAgainFromStableAttributes() throws IOException { + PayloadFixture fixture = payloadFixture(); + AtomicInteger writerCalls = new AtomicInteger(); + LocalPersistentPayloadOperations payload = fixture.payload(); + String stageFileName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + + Throwable failure = + catchThrowable( + () -> + payload.stage( + OPERATION_ID, + stageFileName, + 4, + output -> { + writerCalls.incrementAndGet(); + output.write(new byte[] {1, 2, 3, 4, 5}); + })); + + assertFailureKind(LocalPersistentPayloadOperations.FailureKind.CAPACITY, failure); + assertThat(writerCalls).hasValue(1); + assertThat(stagePath(fixture.root(), OPERATION_ID, stageFileName)).doesNotExist(); + } + + @Test + void writerFailureDeletesOnlyTheCreatedStageAndPreservesTheOriginalFailure() throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations payload = fixture.payload(); + String stageFileName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + InjectedFault original = new InjectedFault("writer"); + + Throwable failure = + catchThrowable( + () -> + payload.stage( + OPERATION_ID, + stageFileName, + 1024, + output -> { + output.write("partial".getBytes(UTF_8)); + throw original; + })); + + assertThat(failure).isSameAs(original); + assertThat(stagePath(fixture.root(), OPERATION_ID, stageFileName)).doesNotExist(); + } + + @Test + void cleanupFailureIsSuppressedOnTheOriginalWriterFailureAndTheResourceIsClosed() + throws IOException { + PayloadFixture fixture = payloadFixture(); + InjectedFault original = new InjectedFault("writer"); + InjectedFault cleanup = new InjectedFault("cleanup"); + AtomicBoolean closeObserved = new AtomicBoolean(); + LocalPersistentPayloadOperations.PayloadAccess access = + new DelegatingPayloadAccess() { + @Override + public void cleanupCreatedStage( + Path topDirectory, + String shard, + String fileName, + String expectedFileKey, + Throwable originalFailure) { + closeObserved.set( + canExclusivelyOpen(stagePath(fixture.root(), OPERATION_ID, fileName))); + throw cleanup; + } + }; + LocalPersistentPayloadOperations payload = + fixture.payload( + access, () -> fixture.attestor().verifyIdentity(fixture.evidence()), ignored -> {}); + + Throwable failure = + catchThrowable( + () -> + payload.stage( + OPERATION_ID, + LocalPersistentPayloadOperations.stageFileName(OPERATION_ID), + 1024, + output -> { + output.write("partial".getBytes(UTF_8)); + throw original; + })); + + assertThat(failure).isSameAs(original); + assertThat(failure.getSuppressed()).containsExactly(cleanup); + assertThat(closeObserved).isTrue(); + } + + @Test + void outerPostForceAttestationFailureExactDeletesTheCreatedStage() throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations.PayloadAccess access = + new DelegatingPayloadAccess() { + @Override + public LocalPersistentPayloadOperations.VerifiedArtifact createStageAndForce( + Path topDirectory, + String shard, + String fileName, + String operationId, + long maximumBytes, + LocalPersistentPayloadOperations.PayloadWriter writer) + throws IOException { + LocalPersistentPayloadOperations.VerifiedArtifact actual = + super.createStageAndForce( + topDirectory, shard, fileName, operationId, maximumBytes, writer); + return new LocalPersistentPayloadOperations.VerifiedArtifact( + actual.kind(), + actual.identity(), + actual.shard(), + actual.fileName(), + actual.byteSize(), + actual.sha256(), + actual.fileKey(), + "unexpected-owner", + actual.permissions(), + actual.fileStoreName(), + actual.fileStoreType()); + } + }; + LocalPersistentPayloadOperations payload = + fixture.payload( + access, () -> fixture.attestor().verifyIdentity(fixture.evidence()), ignored -> {}); + String stageName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INTEGRITY, + catchThrowable( + () -> + payload.stage( + OPERATION_ID, + stageName, + 1024, + output -> output.write("payload".getBytes(UTF_8))))); + assertThat(stagePath(fixture.root(), OPERATION_ID, stageName)).doesNotExist(); + } + + @Test + void inspectionStreamsAndRejectsDigestSizeSymlinkAndOversizedArtifacts() throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations payload = fixture.payload(); + String stageName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage(OPERATION_ID, stageName, 1024, out -> out.write("payload".getBytes(UTF_8))); + + assertThat(payload.inspectStage(OPERATION_ID, stageName, 1024)).contains(staged); + assertThat( + payload.inspectStage( + "missing-operation", + LocalPersistentPayloadOperations.stageFileName("missing-operation"), + 1024)) + .isEmpty(); + + String symlinkOperation = "symlink-operation"; + String symlinkName = LocalPersistentPayloadOperations.stageFileName(symlinkOperation); + Path symlinkShard = + fixture + .root() + .resolve(".ca-fileserver/staging") + .resolve(operationToken(symlinkOperation).substring(0, 2)); + Files.createDirectory( + symlinkShard, PosixFilePermissions.asFileAttribute(ownerDirectoryPermissions())); + Files.createSymbolicLink( + symlinkShard.resolve(symlinkName), stagePath(fixture.root(), OPERATION_ID, stageName)); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INTEGRITY, + catchThrowable(() -> payload.inspectStage(symlinkOperation, symlinkName, 1024))); + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.CAPACITY, + catchThrowable(() -> payload.inspectStage(OPERATION_ID, stageName, 3))); + } + + @Test + void rejectsSymlinkShardInsteadOfFollowingIt() throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations payload = fixture.payload(); + String operationId = "symlink-shard-operation"; + String shard = operationToken(operationId).substring(0, 2); + Path staging = fixture.root().resolve(".ca-fileserver/staging"); + Path external = temporaryDirectory.resolve("external-" + rootSequence.incrementAndGet()); + Files.createDirectory(external); + Files.createSymbolicLink(staging.resolve(shard), external); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INTEGRITY, + catchThrowable( + () -> + payload.stage( + operationId, + LocalPersistentPayloadOperations.stageFileName(operationId), + 1024, + out -> out.write(1)))); + assertThat(external).isEmptyDirectory(); + } + + @Test + void publishesByExclusiveHardLinkAndForcesTheDataDirectoryInOrder() throws IOException { + PayloadFixture fixture = payloadFixture(); + List events = new ArrayList<>(); + LocalPersistentPayloadOperations payload = fixture.payload(events::add); + String stageName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage(OPERATION_ID, stageName, 1024, out -> out.write("payload".getBytes(UTF_8))); + + LocalPersistentPayloadOperations.VerifiedArtifact data = + payload.publishData(staged, FILE_ID, "report.csv", 1024); + + Path stagePath = stagePath(fixture.root(), OPERATION_ID, stageName); + Path dataPath = dataPath(fixture.root(), FILE_ID, "report.csv"); + assertThat(data.kind()).isEqualTo(LocalPersistentPayloadOperations.ArtifactKind.DATA); + assertThat(data.fileKey()).isEqualTo(staged.fileKey()); + assertThat(Files.readAttributes(stagePath, BasicFileAttributes.class).fileKey()) + .isEqualTo(Files.readAttributes(dataPath, BasicFileAttributes.class).fileKey()); + assertThat(payload.inspectData(FILE_ID, "report.csv", 1024)).contains(data); + assertThat(events) + .extracting(LocalPersistentPayloadOperations.FaultContext::point) + .containsExactly( + LocalPersistentPayloadOperations.FaultPoint.STAGE_FORCED, + LocalPersistentPayloadOperations.FaultPoint.DATA_LINKED, + LocalPersistentPayloadOperations.FaultPoint.DATA_DIRECTORY_FORCED); + } + + @Test + void recoveryForcesAnExistingMatchingDataShardWithoutRepublishing() throws IOException { + PayloadFixture fixture = payloadFixture(); + List recoveryEvents = new ArrayList<>(); + LocalPersistentPayloadOperations initial = fixture.payload(); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + initial.stage( + OPERATION_ID, + LocalPersistentPayloadOperations.stageFileName(OPERATION_ID), + 1024, + out -> out.write("payload".getBytes(UTF_8))); + initial.publishData(staged, FILE_ID, "report.csv", 1024); + byte[] before = Files.readAllBytes(dataPath(fixture.root(), FILE_ID, "report.csv")); + + fixture.payload(recoveryEvents::add).forceDataDirectory(FILE_ID); + + assertThat(Files.readAllBytes(dataPath(fixture.root(), FILE_ID, "report.csv"))) + .isEqualTo(before); + assertThat(recoveryEvents) + .extracting(LocalPersistentPayloadOperations.FaultContext::point) + .containsExactly(LocalPersistentPayloadOperations.FaultPoint.DATA_DIRECTORY_FORCED); + } + + @Test + void inspectsLegacyRootArtifactByBoundedNoFollowStreamingWithoutRewritingIt() throws IOException { + PayloadFixture fixture = payloadFixture(); + String legacyName = "월간 export -- legacy 01.csv"; + Path legacyPath = fixture.root().resolve(legacyName); + byte[] bytes = "legacy-payload".getBytes(UTF_8); + Files.write(legacyPath, bytes); + Files.setPosixFilePermissions(legacyPath, PosixFilePermissions.fromString("rw-------")); + var before = Files.readAttributes(legacyPath, BasicFileAttributes.class); + + LocalPersistentPayloadOperations.VerifiedArtifact artifact = + fixture.payload().inspectLegacyRootArtifact(legacyName, bytes.length).orElseThrow(); + + var after = Files.readAttributes(legacyPath, BasicFileAttributes.class); + assertThat(artifact.kind()) + .isEqualTo(LocalPersistentPayloadOperations.ArtifactKind.LEGACY_ROOT); + assertThat(artifact.fileName()).isEqualTo(legacyName); + assertThat(artifact.byteSize()).isEqualTo(bytes.length); + assertThat(artifact.sha256()).isEqualTo(sha256(bytes)); + assertThat(after.fileKey()).isEqualTo(before.fileKey()); + assertThat(after.lastModifiedTime()).isEqualTo(before.lastModifiedTime()); + } + + @Test + void legacyRootInspectionRejectsSymlinkAndCapacityOverflow() throws IOException { + PayloadFixture fixture = payloadFixture(); + Path target = fixture.root().resolve("legacy-target.csv"); + Files.write(target, "payload".getBytes(UTF_8)); + Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rw-------")); + Files.createSymbolicLink(fixture.root().resolve("legacy-link.csv"), target); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INTEGRITY, + catchThrowable(() -> fixture.payload().inspectLegacyRootArtifact("legacy-link.csv", 1024))); + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.CAPACITY, + catchThrowable(() -> fixture.payload().inspectLegacyRootArtifact("legacy-target.csv", 3))); + } + + @Test + void collisionNeverOverwritesExistingTargetAndReturnsTypedConflict() throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations payload = fixture.payload(); + LocalPersistentPayloadOperations.VerifiedArtifact first = + payload.stage( + OPERATION_ID, + LocalPersistentPayloadOperations.stageFileName(OPERATION_ID), + 1024, + out -> out.write("first".getBytes(UTF_8))); + payload.publishData(first, FILE_ID, "report.csv", 1024); + byte[] before = Files.readAllBytes(dataPath(fixture.root(), FILE_ID, "report.csv")); + String secondOperation = "customer/order:02"; + LocalPersistentPayloadOperations.VerifiedArtifact second = + payload.stage( + secondOperation, + LocalPersistentPayloadOperations.stageFileName(secondOperation), + 1024, + out -> out.write("second".getBytes(UTF_8))); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.CONFLICT, + catchThrowable(() -> payload.publishData(second, FILE_ID, "report.csv", 1024))); + assertThat(Files.readAllBytes(dataPath(fixture.root(), FILE_ID, "report.csv"))) + .isEqualTo(before); + } + + @Test + void rechecksStageIdentityImmediatelyBeforeHardLinkAndPreservesReplacement() throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + fixture + .payload() + .stage( + OPERATION_ID, + LocalPersistentPayloadOperations.stageFileName(OPERATION_ID), + 1024, + out -> out.write("original".getBytes(UTF_8))); + Path stagePath = stagePath(fixture.root(), OPERATION_ID, staged.fileName()); + Files.move(stagePath, stagePath.resolveSibling("saved.part")); + Files.write(stagePath, "replacement".getBytes(UTF_8)); + Files.setPosixFilePermissions(stagePath, PosixFilePermissions.fromString("rw-------")); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INTEGRITY, + catchThrowable(() -> fixture.payload().publishData(staged, FILE_ID, "report.csv", 1024))); + assertThat(Files.readString(stagePath)).isEqualTo("replacement"); + assertThat(dataPath(fixture.root(), FILE_ID, "report.csv")).doesNotExist(); + } + + @Test + void exactStageDeleteRejectsReplacementAndForcesOnlyAfterMatchingDelete() throws IOException { + PayloadFixture fixture = payloadFixture(); + List events = new ArrayList<>(); + LocalPersistentPayloadOperations payload = fixture.payload(events::add); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage( + OPERATION_ID, + LocalPersistentPayloadOperations.stageFileName(OPERATION_ID), + 1024, + out -> out.write("original".getBytes(UTF_8))); + Path stagePath = stagePath(fixture.root(), OPERATION_ID, staged.fileName()); + Path original = stagePath.resolveSibling("saved.part"); + Files.move(stagePath, original); + Files.write(stagePath, "replacement".getBytes(UTF_8)); + Files.setPosixFilePermissions(stagePath, PosixFilePermissions.fromString("rw-------")); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INTEGRITY, + catchThrowable(() -> payload.deleteStageExact(staged))); + assertThat(Files.readString(stagePath)).isEqualTo("replacement"); + + Files.delete(stagePath); + Files.move(original, stagePath); + payload.deleteStageExact(staged); + + assertThat(stagePath).doesNotExist(); + assertThat(events) + .extracting(LocalPersistentPayloadOperations.FaultContext::point) + .containsExactly( + LocalPersistentPayloadOperations.FaultPoint.STAGE_FORCED, + LocalPersistentPayloadOperations.FaultPoint.STAGE_DELETED); + } + + @Test + void rootIdentityFailureBeforeMutationCreatesNothingAndAfterCommitIsIndeterminate() + throws IOException { + PayloadFixture fixture = payloadFixture(); + String stageName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + AtomicInteger calls = new AtomicInteger(); + LocalPersistentPayloadOperations beforeFailure = + fixture.payload( + LocalPersistentPayloadOperations.systemPayloadAccess(), + () -> { + if (calls.incrementAndGet() == 1) { + throw new InjectedFault("identity-before"); + } + }, + ignored -> {}); + + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INDETERMINATE, + catchThrowable( + () -> + beforeFailure.stage( + OPERATION_ID, stageName, 1024, out -> out.write("bytes".getBytes(UTF_8))))); + assertThat(stagePath(fixture.root(), OPERATION_ID, stageName)).doesNotExist(); + + calls.set(0); + LocalPersistentPayloadOperations afterFailure = + fixture.payload( + LocalPersistentPayloadOperations.systemPayloadAccess(), + () -> { + if (calls.incrementAndGet() == 2) { + throw new InjectedFault("identity-after"); + } + }, + ignored -> {}); + assertFailureKind( + LocalPersistentPayloadOperations.FailureKind.INDETERMINATE, + catchThrowable( + () -> + afterFailure.stage( + OPERATION_ID, stageName, 1024, out -> out.write("bytes".getBytes(UTF_8))))); + assertThat(stagePath(fixture.root(), OPERATION_ID, stageName)).isRegularFile(); + } + + @Test + void faultCallbacksAfterDurableBoundariesNeverRemoveCommittedArtifacts() throws IOException { + for (LocalPersistentPayloadOperations.FaultPoint point : + List.of( + LocalPersistentPayloadOperations.FaultPoint.STAGE_FORCED, + LocalPersistentPayloadOperations.FaultPoint.DATA_LINKED, + LocalPersistentPayloadOperations.FaultPoint.DATA_DIRECTORY_FORCED)) { + PayloadFixture fixture = payloadFixture(); + InjectedFault fault = new InjectedFault(point.name()); + AtomicBoolean enabled = new AtomicBoolean(true); + LocalPersistentPayloadOperations payload = + fixture.payload( + context -> { + if (enabled.get() && context.point() == point) { + throw fault; + } + }); + String stageName = LocalPersistentPayloadOperations.stageFileName(OPERATION_ID); + + if (point == LocalPersistentPayloadOperations.FaultPoint.STAGE_FORCED) { + assertThatThrownBy( + () -> + payload.stage( + OPERATION_ID, stageName, 1024, out -> out.write("payload".getBytes(UTF_8)))) + .isSameAs(fault); + assertThat(stagePath(fixture.root(), OPERATION_ID, stageName)).isRegularFile(); + } else { + enabled.set(false); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage( + OPERATION_ID, stageName, 1024, out -> out.write("payload".getBytes(UTF_8))); + enabled.set(true); + assertThatThrownBy(() -> payload.publishData(staged, FILE_ID, "report.csv", 1024)) + .isSameAs(fault); + assertThat(dataPath(fixture.root(), FILE_ID, "report.csv")).isRegularFile(); + } + } + } + + @Test + void stageDeletedCallbackRunsAfterDeletionAndItsFailureDoesNotRestoreTheStage() + throws IOException { + PayloadFixture fixture = payloadFixture(); + LocalPersistentPayloadOperations payload = fixture.payload(); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage( + OPERATION_ID, + LocalPersistentPayloadOperations.stageFileName(OPERATION_ID), + 1024, + out -> out.write("payload".getBytes(UTF_8))); + InjectedFault fault = new InjectedFault("after-delete"); + LocalPersistentPayloadOperations failing = + fixture.payload( + context -> { + if (context.point() == LocalPersistentPayloadOperations.FaultPoint.STAGE_DELETED) { + throw fault; + } + }); + + assertThatThrownBy(() -> failing.deleteStageExact(staged)).isSameAs(fault); + assertThat(stagePath(fixture.root(), OPERATION_ID, staged.fileName())).doesNotExist(); + } + + private PayloadFixture payloadFixture() throws IOException { + Path root = + temporaryDirectory + .resolve("payload-root-" + rootSequence.incrementAndGet()) + .toAbsolutePath() + .normalize(); + Files.createDirectory(root, PosixFilePermissions.asFileAttribute(ownerDirectoryPermissions())); + Files.write(root.resolve(SENTINEL_NAME), SENTINEL_CONTENT); + FileStore store = Files.getFileStore(root); + String owner = Files.getOwner(root).getName(); + CompiledFileDestination destination = + new CompiledFileDestination( + new FileDestinationId("local-export"), + "local-primary", + root, + 10_000, + 16 * 1024 * 1024, + store.name(), + store.type(), + SENTINEL_NAME, + sha256(SENTINEL_CONTENT), + owner, + mode(ownerDirectoryPermissions()), + ownerDirectoryPermissions(), + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destination); + return new PayloadFixture(root, attestor, evidence); + } + + private static Path stagePath(Path root, String operationId, String stageFileName) { + return root.resolve(".ca-fileserver/staging") + .resolve(operationToken(operationId).substring(0, 2)) + .resolve(stageFileName); + } + + private static Path dataPath(Path root, String fileId, String publishedFileName) { + return root.resolve("data").resolve(fileId.substring(0, 2)).resolve(publishedFileName); + } + + private static String operationToken(String operationId) { + return sha256(operationId.getBytes(UTF_8)); + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 must be available", exception); + } + } + + private static Set ownerDirectoryPermissions() { + return PosixFilePermissions.fromString("rwx------"); + } + + private static String mode(Set permissions) { + int value = 0; + value |= permissions.contains(PosixFilePermission.OWNER_READ) ? 0400 : 0; + value |= permissions.contains(PosixFilePermission.OWNER_WRITE) ? 0200 : 0; + value |= permissions.contains(PosixFilePermission.OWNER_EXECUTE) ? 0100 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_READ) ? 0040 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_WRITE) ? 0020 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_EXECUTE) ? 0010 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_READ) ? 0004 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_WRITE) ? 0002 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_EXECUTE) ? 0001 : 0; + return String.format("%04o", value); + } + + private static boolean canExclusivelyOpen(Path path) { + try (var ignored = + java.nio.channels.FileChannel.open(path, java.nio.file.StandardOpenOption.WRITE)) { + return true; + } catch (IOException exception) { + return false; + } + } + + private static void assertFailureKind( + LocalPersistentPayloadOperations.FailureKind expected, Throwable failure) { + assertThat(failure) + .isInstanceOf(LocalPersistentPayloadOperations.LocalPersistentPayloadException.class); + assertThat(((LocalPersistentPayloadOperations.LocalPersistentPayloadException) failure).kind()) + .isEqualTo(expected); + } + + private record PayloadFixture( + Path root, LocalPersistentRootAttestor attestor, LocalPersistentRootEvidence evidence) { + + LocalPersistentPayloadOperations payload() { + return new LocalPersistentPayloadOperations(attestor, evidence); + } + + LocalPersistentPayloadOperations payload( + LocalPersistentPayloadOperations.FaultCallback callback) { + return new LocalPersistentPayloadOperations(attestor, evidence, callback); + } + + LocalPersistentPayloadOperations payload( + LocalPersistentPayloadOperations.PayloadAccess access, + LocalPersistentPayloadOperations.IdentityVerifier verifier, + LocalPersistentPayloadOperations.FaultCallback callback) { + return new LocalPersistentPayloadOperations(attestor, evidence, access, verifier, callback); + } + } + + private abstract static class DelegatingPayloadAccess + implements LocalPersistentPayloadOperations.PayloadAccess { + + private final LocalPersistentPayloadOperations.PayloadAccess delegate = + LocalPersistentPayloadOperations.systemPayloadAccess(); + + @Override + public LocalPersistentPayloadOperations.ShardAttributes statShard( + Path topDirectory, String shard) throws IOException { + return delegate.statShard(topDirectory, shard); + } + + @Override + public LocalPersistentPayloadOperations.ShardAttributes createPrivateShard( + Path topDirectory, String shard) throws IOException { + return delegate.createPrivateShard(topDirectory, shard); + } + + @Override + public String fileStoreName(Path path) throws IOException { + return delegate.fileStoreName(path); + } + + @Override + public String fileStoreType(Path path) throws IOException { + return delegate.fileStoreType(path); + } + + @Override + public LocalPersistentPayloadOperations.VerifiedArtifact createStageAndForce( + Path topDirectory, + String shard, + String fileName, + String operationId, + long maximumBytes, + LocalPersistentPayloadOperations.PayloadWriter writer) + throws IOException { + return delegate.createStageAndForce( + topDirectory, shard, fileName, operationId, maximumBytes, writer); + } + + @Override + public LocalPersistentPayloadOperations.VerifiedArtifact inspect( + LocalPersistentPayloadOperations.ArtifactKind kind, + Path topDirectory, + String shard, + String fileName, + String identity, + long maximumBytes) + throws IOException { + return delegate.inspect(kind, topDirectory, shard, fileName, identity, maximumBytes); + } + + @Override + public void createHardLink(Path link, Path existing) throws IOException { + delegate.createHardLink(link, existing); + } + + @Override + public void forceDirectory(Path directory) throws IOException { + delegate.forceDirectory(directory); + } + + @Override + public void cleanupCreatedStage( + Path topDirectory, + String shard, + String fileName, + String expectedFileKey, + Throwable originalFailure) + throws IOException { + delegate.cleanupCreatedStage(topDirectory, shard, fileName, expectedFileKey, originalFailure); + } + + @Override + public boolean deleteExact( + Path topDirectory, + String shard, + String fileName, + LocalPersistentPayloadOperations.VerifiedArtifact expected) + throws IOException { + return delegate.deleteExact(topDirectory, shard, fileName, expected); + } + } + + private static final class InjectedFault extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private InjectedFault(String message) { + super(message); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java new file mode 100644 index 0000000..0b94b85 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProviderTest.java @@ -0,0 +1,511 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.filepublication.ExportSchema; +import dev.caskeleton.application.filepublication.ExportSchema.CellType; +import dev.caskeleton.application.filepublication.ExportSchema.Column; +import dev.caskeleton.application.filepublication.ExportSchema.FormulaPolicy; +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublishOperationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.FilePublishRequest; +import dev.caskeleton.application.filepublication.LogicalFileName; +import dev.caskeleton.application.filepublication.SourceRevision; +import dev.caskeleton.application.filepublication.TabularCell.IntegerCell; +import dev.caskeleton.application.filepublication.TabularCell.TextCell; +import dev.caskeleton.application.filepublication.TabularRow; +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class LocalPersistentPublicationProviderTest { + + @TempDir Path temporaryDirectory; + + @ParameterizedTest + @ValueSource(strings = {"sourceRevision", "logicalFileName"}) + void malformedUnicodeIsRejectedBeforeProducerFileIdAndControlMutation(String malformedField) + throws IOException { + LocalPersistentPublicationTestFixture fixture = + LocalPersistentPublicationTestFixture.create( + temporaryDirectory.resolve("malformed-" + malformedField)); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + LocalPersistentPublicationProvider provider = + fixture.provider( + controlPlane, + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return fixture.fileId(); + }); + FilePublishRequest valid = fixture.request("source-42"); + FilePublishRequest malformed = + new FilePublishRequest( + valid.operationId(), + valid.destinationId(), + malformedField.equals("logicalFileName") + ? new LogicalFileName("report-\ud800") + : valid.logicalFileName(), + malformedField.equals("sourceRevision") + ? new SourceRevision("source-\ud800") + : valid.sourceRevision(), + valid.schema(), + valid.formatProfileId()); + + assertThatThrownBy( + () -> + provider.publish( + malformed, + sink -> { + producerCalls.incrementAndGet(); + throw new AssertionError("invalid request must not invoke producer"); + })) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.INVALID_REQUEST)); + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + assertThat(controlPlane.findStoredOperation(valid.operationId().value())).isEmpty(); + } + + @Test + void concurrentCallsForOneOperationInvokeProducerExactlyOnceAndRestoreOneReceipt() + throws Exception { + LocalPersistentPublicationTestFixture fixture = + LocalPersistentPublicationTestFixture.create(temporaryDirectory.resolve("concurrent")); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + CountDownLatch firstProducerEntered = new CountDownLatch(1); + CountDownLatch releaseFirstProducer = new CountDownLatch(1); + CountDownLatch secondCallStarted = new CountDownLatch(1); + LocalPersistentPublicationProvider provider = + fixture.provider( + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return fixture.fileId(); + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + Future first = null; + Future second = null; + try { + first = + executor.submit( + () -> + provider.publish( + fixture.request("source-42"), + sink -> { + producerCalls.incrementAndGet(); + firstProducerEntered.countDown(); + await(releaseFirstProducer); + sink.write( + new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd")))); + })); + assertThat(firstProducerEntered.await(2, TimeUnit.SECONDS)).isTrue(); + second = + executor.submit( + () -> { + secondCallStarted.countDown(); + return provider.publish( + fixture.request("source-42"), + sink -> { + producerCalls.incrementAndGet(); + throw new AssertionError("serialized retry must not invoke producer"); + }); + }); + assertThat(secondCallStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Future blockedSecond = second; + assertThatThrownBy(() -> blockedSecond.get(150, TimeUnit.MILLISECONDS)) + .isInstanceOf(TimeoutException.class); + + releaseFirstProducer.countDown(); + assertThat(second.get(5, TimeUnit.SECONDS)).isEqualTo(first.get(5, TimeUnit.SECONDS)); + assertThat(producerCalls).hasValue(1); + assertThat(fileIdCalls).hasValue(1); + } finally { + releaseFirstProducer.countDown(); + cancel(first); + cancel(second); + executor.shutdownNow(); + assertThat(executor.awaitTermination(2, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void rejectsCrossWiredRootsProviderIdsAndSeparateRuntimeInstances() throws IOException { + LocalPersistentPublicationTestFixture first = + LocalPersistentPublicationTestFixture.create(temporaryDirectory.resolve("wiring-first")); + LocalPersistentPublicationTestFixture second = + LocalPersistentPublicationTestFixture.create(temporaryDirectory.resolve("wiring-second")); + FileDestinationId secondaryId = new FileDestinationId("secondary-export"); + CompiledFileDestination secondary = + first.destinationFor(secondaryId, first.destination().providerId()); + CompiledFileDestination otherProvider = first.destinationFor(secondaryId, "other-primary"); + LocalPersistentControlPlane sharedControl = first.controlPlane(); + LocalPersistentPayloadOperations sharedPayload = first.payload(); + LocalPersistentPublicationProvider.DestinationRuntime primaryRuntime = + new LocalPersistentPublicationProvider.DestinationRuntime( + first.destination(), sharedControl, sharedPayload); + + assertThatThrownBy( + () -> + new LocalPersistentPublicationProvider( + Map.of( + LocalPersistentPublicationTestFixture.DESTINATION, + new LocalPersistentPublicationProvider.DestinationRuntime( + first.destination(), second.controlPlane(), second.payload())), + LocalPersistentPublicationTestFixture.FIXED_CLOCK, + first::fileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("root"); + assertThatThrownBy( + () -> + new LocalPersistentPublicationProvider( + Map.of( + LocalPersistentPublicationTestFixture.DESTINATION, + primaryRuntime, + secondaryId, + new LocalPersistentPublicationProvider.DestinationRuntime( + otherProvider, sharedControl, sharedPayload)), + LocalPersistentPublicationTestFixture.FIXED_CLOCK, + first::fileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider ID"); + assertThatThrownBy( + () -> + new LocalPersistentPublicationProvider( + Map.of( + LocalPersistentPublicationTestFixture.DESTINATION, + primaryRuntime, + secondaryId, + new LocalPersistentPublicationProvider.DestinationRuntime( + secondary, first.controlPlane(), first.payload())), + LocalPersistentPublicationTestFixture.FIXED_CLOCK, + first::fileId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("shared runtime"); + } + + @Test + void publishesOnceInTheExactDurableOrderAndReturnsOnlyAnOpaqueR2Receipt() throws IOException { + LocalPersistentPublicationTestFixture fixture = + LocalPersistentPublicationTestFixture.create(temporaryDirectory.resolve("publish")); + DurableOrderRecorder order = new DurableOrderRecorder(); + AtomicInteger producerCalls = new AtomicInteger(); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(order::record); + LocalPersistentPublicationProvider provider = + fixture.provider(controlPlane, fixture.payload(order::record), () -> fixture.fileId()); + + FilePublishReceipt receipt = + provider.publish( + fixture.request("source-42"), + sink -> { + producerCalls.incrementAndGet(); + sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd")))); + }); + + assertThat(producerCalls).hasValue(1); + assertThat(order.events()) + .containsExactly( + "J_WRITING", + "STAGE_FORCED", + "J_SEALED", + "DATA_LINKED", + "DATA_DIRECTORY_FORCED", + "J_DATA_PUBLISHED", + "MANIFEST_FORCED", + "J_MANIFEST_PUBLISHED", + "REFERENCE_FORCED", + "J_REFERENCE_PUBLISHED", + "J_PUBLISHED"); + assertThat(receipt.operationId()).isEqualTo(fixture.request("source-42").operationId()); + assertThat(receipt.destinationId()) + .isEqualTo(LocalPersistentPublicationTestFixture.DESTINATION); + assertThat(receipt.reference().value()) + .startsWith("fsr1.") + .doesNotContain(fixture.root().toString()); + assertThat(receipt.publishedFileName()).endsWith(".csv").doesNotContain("/", "\\"); + assertThat(receipt.publishedAt()).isEqualTo(LocalPersistentPublicationTestFixture.PUBLISHED_AT); + assertThat(receipt.publicationGuarantee()).isEqualTo(PublicationGuarantee.UNIQUE_ATOMIC_CREATE); + assertThat(receipt.durabilityGuarantee()) + .isEqualTo(DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + assertThat(receipt.dataRowCount()).isEqualTo(1); + assertThat(receipt.columnCount()).isEqualTo(2); + assertThat(receipt.formulaMitigatedCount()).isEqualTo(1); + assertThat(receipt.sha256()) + .isEqualTo( + LocalPersistentPublicationTestFixture.sha256("id,note\n1,'=cmd\n".getBytes(UTF_8))); + + R2PublishedReferenceCodec.DecodedReference decoded = + new R2PublishedReferenceCodec() + .decode(receipt.reference(), Set.of(fixture.destination().routeToken())); + assertThat(decoded.fileId()).isEqualTo(fixture.fileId()); + DurablePublicationRecord operation = + controlPlane.findOperation(fixture.operationId()).orElseThrow(); + PrivateFileManifest manifest = controlPlane.findManifest(decoded.fileId()).orElseThrow(); + PublishedReferenceRecord reference = controlPlane.findReference(decoded.fileId()).orElseThrow(); + assertThat(operation.state()).isEqualTo(DurablePublicationRecord.State.PUBLISHED); + assertThat( + new FileserverControlRecordCodec().decodeReceiptSnapshot(operation.receiptSnapshot())) + .isEqualTo(receipt); + assertThat(manifest.internalLocator()) + .isEqualTo(receipt.publishedFileName()) + .doesNotContain("/", "\\", fixture.root().toString()); + assertThat(reference.internalLocator()).isEqualTo(manifest.internalLocator()); + assertThat(reference.manifestDigest()).isEqualTo(operation.manifestDigest()); + assertThat( + fixture + .root() + .resolve("data") + .resolve(fixture.fileId().substring(0, 2)) + .resolve(manifest.internalLocator())) + .hasBinaryContent("id,note\n1,'=cmd\n".getBytes(UTF_8)); + } + + private static final class DurableOrderRecorder { + + private final List events = new ArrayList<>(); + + void record(LocalPersistentControlPlane.FaultContext context) { + if (context.boundary() != LocalPersistentControlPlane.FaultPoint.PARENT_FORCED) { + return; + } + switch (context.recordKind()) { + case OPERATION -> events.add("J_" + context.operation().orElseThrow().state().name()); + case MANIFEST -> events.add("MANIFEST_FORCED"); + case REFERENCE -> events.add("REFERENCE_FORCED"); + default -> throw new IllegalStateException("unsupported control record kind"); + } + } + + void record(LocalPersistentPayloadOperations.FaultContext context) { + if (context.point() != LocalPersistentPayloadOperations.FaultPoint.STAGE_DELETED) { + events.add(context.point().name()); + } + } + + List events() { + return List.copyOf(events); + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting for concurrent publication release"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("concurrent publication interrupted", exception); + } + } + + private static void cancel(Future future) { + if (future != null && !future.isDone()) { + future.cancel(true); + } + } +} + +final class LocalPersistentPublicationTestFixture { + + static final FileDestinationId DESTINATION = new FileDestinationId("local-export"); + static final Instant PUBLISHED_AT = Instant.parse("2026-07-28T01:02:17Z"); + static final Clock FIXED_CLOCK = Clock.fixed(PUBLISHED_AT, ZoneOffset.UTC); + static final String FILE_ID = "00112233445566778899aabbccddeeff"; + static final String SENTINEL_NAME = ".ca-fileserver-volume"; + static final byte[] SENTINEL_CONTENT = "fileserver-r2-provider-root\n".getBytes(UTF_8); + + private final Path root; + private final CompiledFileDestination destination; + private final LocalPersistentRootAttestor attestor; + private final LocalPersistentRootEvidence evidence; + + private LocalPersistentPublicationTestFixture( + Path root, + CompiledFileDestination destination, + LocalPersistentRootAttestor attestor, + LocalPersistentRootEvidence evidence) { + this.root = root; + this.destination = destination; + this.attestor = attestor; + this.evidence = evidence; + } + + static LocalPersistentPublicationTestFixture create(Path root) throws IOException { + Path normalizedRoot = root.toAbsolutePath().normalize(); + Files.createDirectory( + normalizedRoot, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Files.write(normalizedRoot.resolve(SENTINEL_NAME), SENTINEL_CONTENT); + FileStore store = Files.getFileStore(normalizedRoot); + Set rootMode = PosixFilePermissions.fromString("rwx------"); + CompiledFileDestination destination = + new CompiledFileDestination( + DESTINATION, + "local-primary", + normalizedRoot, + 10_000, + 16 * 1024 * 1024, + store.name(), + store.type(), + SENTINEL_NAME, + sha256(SENTINEL_CONTENT), + Files.getOwner(normalizedRoot).getName(), + "0700", + rootMode, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destination); + return new LocalPersistentPublicationTestFixture( + normalizedRoot, destination, attestor, evidence); + } + + LocalPersistentControlPlane controlPlane() { + return new LocalPersistentControlPlane(attestor, evidence); + } + + LocalPersistentControlPlane controlPlane( + LocalPersistentControlPlane.ContextualFaultCallback callback) { + return LocalPersistentControlPlane.withContextualFaultCallback(attestor, evidence, callback); + } + + LocalPersistentPayloadOperations payload() { + return new LocalPersistentPayloadOperations(attestor, evidence); + } + + LocalPersistentPayloadOperations payload( + LocalPersistentPayloadOperations.FaultCallback callback) { + return new LocalPersistentPayloadOperations(attestor, evidence, callback); + } + + LocalPersistentPublicationProvider provider() { + return provider(controlPlane(), payload(), () -> FILE_ID); + } + + LocalPersistentPublicationProvider provider( + LocalPersistentControlPlane controlPlane, + LocalPersistentPayloadOperations payload, + LocalPersistentPublicationProvider.FileIdGenerator fileIds) { + return provider(destination, controlPlane, payload, fileIds); + } + + LocalPersistentPublicationProvider provider( + CompiledFileDestination effectiveDestination, + LocalPersistentControlPlane controlPlane, + LocalPersistentPayloadOperations payload, + LocalPersistentPublicationProvider.FileIdGenerator fileIds) { + LocalPersistentPublicationProvider.DestinationRuntime runtime = + new LocalPersistentPublicationProvider.DestinationRuntime( + effectiveDestination, controlPlane, payload); + return new LocalPersistentPublicationProvider( + Map.of(DESTINATION, runtime), FIXED_CLOCK, fileIds); + } + + CompiledFileDestination destinationWithLimits(long maximumRows, long maximumEncodedBytes) { + return destinationFor( + destination.destinationId(), destination.providerId(), maximumRows, maximumEncodedBytes); + } + + CompiledFileDestination destinationFor(FileDestinationId destinationId, String providerId) { + return destinationFor( + destinationId, providerId, destination.maximumRows(), destination.maximumEncodedBytes()); + } + + private CompiledFileDestination destinationFor( + FileDestinationId destinationId, + String providerId, + long maximumRows, + long maximumEncodedBytes) { + return new CompiledFileDestination( + destinationId, + providerId, + destination.rootDirectory(), + maximumRows, + maximumEncodedBytes, + destination.expectedFileStoreName(), + destination.expectedFileStoreType(), + destination.mountSentinelName(), + destination.mountSentinelSha256(), + destination.expectedOwner(), + destination.maximumRootMode(), + destination.maximumRootPermissions(), + destination.requiredPublicationGuarantee(), + destination.requiredDurabilityGuarantee()); + } + + FilePublishRequest request(String sourceRevision) { + return new FilePublishRequest( + new FilePublishOperationId(operationId()), + DESTINATION, + new LogicalFileName("report"), + new SourceRevision(sourceRevision), + new ExportSchema( + "worklog-v1", + 1, + List.of( + new Column("id", CellType.INTEGER, false, FormulaPolicy.REJECT, 64), + new Column("note", CellType.TEXT, false, FormulaPolicy.MITIGATE, 256))), + "csv-rfc4180-v1"); + } + + Path root() { + return root; + } + + CompiledFileDestination destination() { + return destination; + } + + String operationId() { + return "operation-r2-01"; + } + + String fileId() { + return FILE_ID; + } + + static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 must be available", exception); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java new file mode 100644 index 0000000..9400ea3 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationRecoveryTest.java @@ -0,0 +1,1121 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import dev.caskeleton.application.filepublication.FilePublicationException; +import dev.caskeleton.application.filepublication.FilePublishReceipt; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import dev.caskeleton.application.filepublication.PublishedFileReference; +import dev.caskeleton.application.filepublication.TabularCell.IntegerCell; +import dev.caskeleton.application.filepublication.TabularCell.TextCell; +import dev.caskeleton.application.filepublication.TabularRow; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class LocalPersistentPublicationRecoveryTest { + + @TempDir Path temporaryDirectory; + private final AtomicInteger fixtureSequence = new AtomicInteger(); + + @Test + void sealedStageResumesFromDurableBytesWithoutReplayingTheProducer() throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("sealed-stage"); + PublishedBaseline baseline = publishBaseline(fixture); + LocalPersistentPayloadOperations payload = fixture.payload(); + Path data = baseline.dataPath(); + Path preservedData = fixture.root().resolve("preserved-data"); + Files.move(data, preservedData, StandardCopyOption.ATOMIC_MOVE); + forceDirectory(data.getParent()); + payload.stage( + fixture.operationId(), + baseline.operation().stageFileName(), + baseline.operation().byteSize(), + output -> Files.copy(preservedData, output)); + Files.delete(preservedData); + forceDirectory(fixture.root()); + removeControlRecord(baseline.referencePath()); + removeControlRecord(baseline.manifestPath()); + rewriteOperation( + baseline.operationPath(), + atState(baseline.operation(), DurablePublicationRecord.State.SEALED)); + + FilePublishReceipt restored = recoverWithoutProducer(fixture); + + assertThat(restored).isEqualTo(baseline.receipt()); + assertThat(data).hasBinaryContent(baseline.payload()); + assertThat(fixture.controlPlane().findOperation(fixture.operationId()).orElseThrow().state()) + .isEqualTo(DurablePublicationRecord.State.PUBLISHED); + } + + @Test + void sealedMatchingDataIsReverifiedAndResumesAtManifestWithoutProducerReplay() + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("sealed-data"); + PublishedBaseline baseline = publishBaseline(fixture); + removeControlRecord(baseline.referencePath()); + removeControlRecord(baseline.manifestPath()); + rewriteOperation( + baseline.operationPath(), + atState(baseline.operation(), DurablePublicationRecord.State.SEALED)); + + FilePublishReceipt restored = recoverWithoutProducer(fixture); + + assertThat(restored).isEqualTo(baseline.receipt()); + assertThat(baseline.dataPath()).hasBinaryContent(baseline.payload()); + } + + @ParameterizedTest + @EnumSource( + value = DurablePublicationRecord.State.class, + names = {"DATA_PUBLISHED", "MANIFEST_PUBLISHED", "REFERENCE_PUBLISHED"}) + void resumesEveryDurableMetadataStateWithoutProducerReplay(DurablePublicationRecord.State state) + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture(state.name().toLowerCase(Locale.ROOT)); + PublishedBaseline baseline = publishBaseline(fixture); + if (state == DurablePublicationRecord.State.DATA_PUBLISHED) { + removeControlRecord(baseline.referencePath()); + removeControlRecord(baseline.manifestPath()); + } else if (state == DurablePublicationRecord.State.MANIFEST_PUBLISHED) { + removeControlRecord(baseline.referencePath()); + } + rewriteOperation(baseline.operationPath(), atState(baseline.operation(), state)); + + FilePublishReceipt restored = recoverWithoutProducer(fixture); + + assertThat(restored).isEqualTo(baseline.receipt()); + assertThat(fixture.controlPlane().findOperation(fixture.operationId()).orElseThrow().state()) + .isEqualTo(DurablePublicationRecord.State.PUBLISHED); + } + + @Test + void publishedOperationRestoresTheExactReceiptWithoutProducerOrFileIdAllocation() + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("published-restore"); + PublishedBaseline baseline = publishBaseline(fixture); + + FilePublishReceipt restored = recoverWithoutProducer(fixture); + + assertThat(restored).isEqualTo(baseline.receipt()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void existingWritingOrUnsealedQuarantineNeverReplaysProducer(boolean alreadyQuarantined) + throws IOException { + LocalPersistentPublicationTestFixture fixture = + fixture(alreadyQuarantined ? "existing-quarantine" : "existing-writing"); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + DurablePublicationRecord writing = writingRecord(fixture); + controlPlane.storeOperation(writing); + if (alreadyQuarantined) { + controlPlane.storeOperation(unsealedQuarantined(writing, "STAGE_FAILED")); + } + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + + assertThatThrownBy( + () -> + fixture + .provider( + controlPlane, + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return "f".repeat(32); + }) + .publish(fixture.request("source-42"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + DurablePublicationRecord retained = + controlPlane.findOperation(fixture.operationId()).orElseThrow(); + assertThat(retained.state()).isEqualTo(DurablePublicationRecord.State.QUARANTINED); + assertThat(retained.sha256()).isEmpty(); + assertThat(stagePath(fixture, retained)).doesNotExist(); + } + + @Test + void reusedOperationWithDifferentFingerprintIsConflictWithoutMutationOrReplay() + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("fingerprint-conflict"); + PublishedBaseline baseline = publishBaseline(fixture); + byte[] operationEvidence = Files.readAllBytes(baseline.operationPath()); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + + assertThatThrownBy( + () -> + fixture + .provider( + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return "f".repeat(32); + }) + .publish( + fixture.request("different-source"), + sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()).isEqualTo(FilePublicationException.Reason.CONFLICT)); + + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + assertThat(baseline.operationPath()).hasBinaryContent(operationEvidence); + } + + @Test + void currentEffectivePolicyMismatchFailsClosedWithoutReinterpretingStoredEvidence() + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("policy-mismatch"); + PublishedBaseline baseline = publishBaseline(fixture); + byte[] operationEvidence = Files.readAllBytes(baseline.operationPath()); + CompiledFileDestination changedPolicy = + fixture.destinationWithLimits( + fixture.destination().maximumRows() + 1, fixture.destination().maximumEncodedBytes()); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + + assertThatThrownBy( + () -> + fixture + .provider( + changedPolicy, + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return "f".repeat(32); + }) + .publish(fixture.request("source-42"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + assertThat(baseline.operationPath()).hasBinaryContent(operationEvidence); + } + + @ParameterizedTest + @ValueSource(strings = {"sentinel", "root"}) + void attestedRootIdentityChangeFailsBeforeProducerAndFileIdAllocation(String change) + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("identity-" + change); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + LocalPersistentPublicationProvider provider = + fixture.provider( + controlPlane, + fixture.payload(), + () -> { + throw new AssertionError("identity failure must precede file ID allocation"); + }); + if (change.equals("sentinel")) { + Path sentinel = fixture.root().resolve(LocalPersistentPublicationTestFixture.SENTINEL_NAME); + Files.write( + sentinel, + "changed-sentinel\n".getBytes(UTF_8), + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + forceFile(sentinel); + forceDirectory(fixture.root()); + } else { + Path attestedRoot = fixture.root().resolveSibling(fixture.root().getFileName() + "-attested"); + Files.move(fixture.root(), attestedRoot, StandardCopyOption.ATOMIC_MOVE); + Files.createDirectory( + fixture.root(), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Files.write( + fixture.root().resolve(LocalPersistentPublicationTestFixture.SENTINEL_NAME), + LocalPersistentPublicationTestFixture.SENTINEL_CONTENT); + forceDirectory(fixture.root().getParent()); + } + AtomicInteger producerCalls = new AtomicInteger(); + + assertThatThrownBy( + () -> + provider.publish( + fixture.request("source-42"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + assertThat(producerCalls).hasValue(0); + } + + @Test + void sealedStageAndDataWithEqualBytesButDifferentFileKeysAreQuarantined() throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("split-hard-link"); + PublishedBaseline baseline = publishBaseline(fixture); + LocalPersistentPayloadOperations payload = fixture.payload(); + LocalPersistentPayloadOperations.VerifiedArtifact staged = + payload.stage( + fixture.operationId(), + baseline.operation().stageFileName(), + baseline.operation().byteSize(), + output -> Files.copy(baseline.dataPath(), output)); + LocalPersistentPayloadOperations.VerifiedArtifact data = + payload + .inspectData( + fixture.fileId(), + baseline.operation().publishedFileName(), + baseline.operation().byteSize()) + .orElseThrow(); + assertThat(staged.sha256()).isEqualTo(data.sha256()); + assertThat(staged.fileKey()).isNotEqualTo(data.fileKey()); + prepareAtState(baseline, DurablePublicationRecord.State.SEALED); + + DurablePublicationRecord quarantined = recoverAndRequireQuarantine(fixture); + + assertThat(quarantined.lastFailureCode()).isEqualTo("RECOVERY_INTEGRITY"); + assertThat(stagePath(fixture, quarantined)).hasBinaryContent(baseline.payload()); + assertThat(baseline.dataPath()).hasBinaryContent(baseline.payload()); + } + + @Test + void sealedWithoutStageOrDataIsQuarantinedWithoutProducerReplay() throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("sealed-missing-all"); + PublishedBaseline baseline = publishBaseline(fixture); + Files.delete(baseline.dataPath()); + forceDirectory(baseline.dataPath().getParent()); + prepareAtState(baseline, DurablePublicationRecord.State.SEALED); + + DurablePublicationRecord quarantined = recoverAndRequireQuarantine(fixture); + + assertThat(quarantined.lastFailureCode()).isEqualTo("RECOVERY_INTEGRITY"); + assertThat(stagePath(fixture, quarantined)).doesNotExist(); + assertThat(baseline.dataPath()).doesNotExist(); + } + + @Test + void dataPublishedRetryDeletesResidualMatchingStageAndConverges() throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("residual-stage"); + PublishedBaseline baseline = publishBaseline(fixture); + Path residualStage = stagePath(fixture, baseline.operation()); + Files.createLink(residualStage, baseline.dataPath()); + forceDirectory(residualStage.getParent()); + assertThat(Files.isSameFile(residualStage, baseline.dataPath())).isTrue(); + prepareAtState(baseline, DurablePublicationRecord.State.DATA_PUBLISHED); + + FilePublishReceipt restored = recoverWithoutProducer(fixture); + + assertThat(restored).isEqualTo(baseline.receipt()); + assertThat(residualStage).doesNotExist(); + assertThat(baseline.dataPath()).hasBinaryContent(baseline.payload()); + } + + @ParameterizedTest + @EnumSource( + value = DurablePublicationRecord.State.class, + names = {"DATA_PUBLISHED", "MANIFEST_PUBLISHED", "REFERENCE_PUBLISHED"}) + void missingRequiredArtifactAtEveryNonTerminalStateIsQuarantined( + DurablePublicationRecord.State state) throws IOException { + LocalPersistentPublicationTestFixture fixture = + fixture("missing-" + state.name().toLowerCase(Locale.ROOT)); + PublishedBaseline baseline = publishBaseline(fixture); + prepareAtState(baseline, state); + switch (state) { + case DATA_PUBLISHED -> { + Files.delete(baseline.dataPath()); + forceDirectory(baseline.dataPath().getParent()); + } + case MANIFEST_PUBLISHED -> removeControlRecord(baseline.manifestPath()); + case REFERENCE_PUBLISHED -> removeControlRecord(baseline.referencePath()); + default -> throw new AssertionError("unexpected recovery fixture state " + state); + } + + DurablePublicationRecord quarantined = recoverAndRequireQuarantine(fixture); + + assertThat(quarantined.lastFailureCode()).isEqualTo("RECOVERY_INTEGRITY"); + } + + @ParameterizedTest + @EnumSource( + value = DurablePublicationRecord.State.class, + names = {"DATA_PUBLISHED", "MANIFEST_PUBLISHED", "REFERENCE_PUBLISHED"}) + void mismatchedEvidenceAtEveryNonTerminalStateIsPreservedAndQuarantined( + DurablePublicationRecord.State state) throws IOException { + LocalPersistentPublicationTestFixture fixture = + fixture("mismatch-" + state.name().toLowerCase(Locale.ROOT)); + PublishedBaseline baseline = publishBaseline(fixture); + prepareAtState(baseline, state); + Path mismatchedEvidence; + switch (state) { + case DATA_PUBLISHED -> { + Files.write( + baseline.dataPath(), + "same-state-wrong-data".getBytes(UTF_8), + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + forceFile(baseline.dataPath()); + forceDirectory(baseline.dataPath().getParent()); + mismatchedEvidence = baseline.dataPath(); + } + case MANIFEST_PUBLISHED -> { + PrivateFileManifest manifest = + fixture.controlPlane().findManifest(fixture.fileId()).orElseThrow(); + rewriteRecord( + baseline.manifestPath(), + new FileserverControlRecordCodec() + .encodeManifest(manifestWithSchemaId(manifest, "different-schema"))); + mismatchedEvidence = baseline.manifestPath(); + } + case REFERENCE_PUBLISHED -> { + PublishedReferenceRecord reference = + fixture.controlPlane().findReference(fixture.fileId()).orElseThrow(); + rewriteRecord( + baseline.referencePath(), + new FileserverControlRecordCodec() + .encodeReference(referenceWithMediaType(reference, "application/csv"))); + mismatchedEvidence = baseline.referencePath(); + } + default -> throw new AssertionError("unexpected recovery fixture state " + state); + } + byte[] mismatch = Files.readAllBytes(mismatchedEvidence); + + DurablePublicationRecord quarantined = recoverAndRequireQuarantine(fixture); + + assertThat(quarantined.lastFailureCode()).isIn("RECOVERY_INTEGRITY", "PAYLOAD_INTEGRITY"); + assertThat(mismatchedEvidence).hasBinaryContent(mismatch); + } + + @Test + void producerFailureAfterWritingJournalLeavesUnsealedQuarantineAndSuppressesControlFailure() + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("writing-failure"); + RuntimeException sourceFailure = new IllegalStateException("source failed"); + RuntimeException quarantineCallbackFailure = + new IllegalStateException("quarantine callback failed"); + LocalPersistentControlPlane controlPlane = + fixture.controlPlane( + context -> { + if (context.recordKind() == LocalPersistentControlPlane.ControlRecordKind.OPERATION + && context.boundary() == LocalPersistentControlPlane.FaultPoint.PARENT_FORCED + && context.operation().orElseThrow().state() + == DurablePublicationRecord.State.QUARANTINED) { + throw quarantineCallbackFailure; + } + }); + LocalPersistentPublicationProvider provider = + fixture.provider(controlPlane, fixture.payload(), () -> fixture.fileId()); + + Throwable failure = + catchThrowable( + () -> + provider.publish( + fixture.request("source-42"), + sink -> { + sink.write( + new TabularRow(List.of(new IntegerCell(1), new TextCell("partial")))); + throw sourceFailure; + })); + + assertThat(failure).isSameAs(sourceFailure); + assertThat(failure.getSuppressed()).contains(quarantineCallbackFailure); + DurablePublicationRecord quarantined = + controlPlane.findOperation(fixture.operationId()).orElseThrow(); + assertThat(quarantined.state()).isEqualTo(DurablePublicationRecord.State.QUARANTINED); + assertThat(quarantined.sha256()).isEmpty(); + assertThat(stagePath(fixture, quarantined)).doesNotExist(); + + AtomicInteger retryProducerCalls = new AtomicInteger(); + assertThatThrownBy( + () -> + fixture + .provider() + .publish( + fixture.request("source-42"), sink -> retryProducerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + assertThat(retryProducerCalls).hasValue(0); + } + + @Test + void publishedMismatchPreservesTerminalJournalManifestReferenceAndMismatchedData() + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("terminal-mismatch"); + PublishedBaseline baseline = publishBaseline(fixture); + byte[] operationEvidence = Files.readAllBytes(baseline.operationPath()); + byte[] manifestEvidence = Files.readAllBytes(baseline.manifestPath()); + byte[] referenceEvidence = Files.readAllBytes(baseline.referencePath()); + byte[] mismatch = "tampered-terminal-data".getBytes(UTF_8); + Files.write( + baseline.dataPath(), + mismatch, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + forceFile(baseline.dataPath()); + forceDirectory(baseline.dataPath().getParent()); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + LocalPersistentPublicationProvider provider = + fixture.provider( + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return "f".repeat(32); + }); + + assertThatThrownBy( + () -> + provider.publish( + fixture.request("source-42"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + assertThat(baseline.operationPath()).hasBinaryContent(operationEvidence); + assertThat(baseline.manifestPath()).hasBinaryContent(manifestEvidence); + assertThat(baseline.referencePath()).hasBinaryContent(referenceEvidence); + assertThat(baseline.dataPath()).hasBinaryContent(mismatch); + assertThat(fixture.controlPlane().findOperation(fixture.operationId()).orElseThrow().state()) + .isEqualTo(DurablePublicationRecord.State.PUBLISHED); + } + + @ParameterizedTest + @ValueSource(strings = {"receipt", "manifest", "reference"}) + void alteredTerminalMetadataRemainsByteForByteImmutableAndNeverBecomesQuarantine( + String alteredKind) throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("terminal-" + alteredKind); + PublishedBaseline baseline = publishBaseline(fixture); + FileserverControlRecordCodec codec = new FileserverControlRecordCodec(); + if (alteredKind.equals("receipt")) { + FilePublishReceipt alteredReceipt = + receiptWithFormat(baseline.receipt(), "csv-rfc4180-altered"); + rewriteOperation( + baseline.operationPath(), + publishedWithReceipt(baseline.operation(), codec.encodeReceiptSnapshot(alteredReceipt))); + } else if (alteredKind.equals("manifest")) { + PrivateFileManifest manifest = + fixture.controlPlane().findManifest(fixture.fileId()).orElseThrow(); + rewriteRecord( + baseline.manifestPath(), + codec.encodeManifest(manifestWithSchemaId(manifest, "different-schema"))); + } else { + PublishedReferenceRecord reference = + fixture.controlPlane().findReference(fixture.fileId()).orElseThrow(); + rewriteRecord( + baseline.referencePath(), + codec.encodeReference(referenceWithMediaType(reference, "application/csv"))); + } + byte[] operationEvidence = Files.readAllBytes(baseline.operationPath()); + byte[] manifestEvidence = Files.readAllBytes(baseline.manifestPath()); + byte[] referenceEvidence = Files.readAllBytes(baseline.referencePath()); + byte[] dataEvidence = Files.readAllBytes(baseline.dataPath()); + + expectIndeterminateWithoutProducerOrFileId(fixture); + + assertThat(baseline.operationPath()).hasBinaryContent(operationEvidence); + assertThat(baseline.manifestPath()).hasBinaryContent(manifestEvidence); + assertThat(baseline.referencePath()).hasBinaryContent(referenceEvidence); + assertThat(baseline.dataPath()).hasBinaryContent(dataEvidence); + assertThat(fixture.controlPlane().findOperation(fixture.operationId()).orElseThrow().state()) + .isEqualTo(DurablePublicationRecord.State.PUBLISHED); + } + + @Test + void restoresCanonicalR1ArtifactInPlaceWithoutPromotingOrRewritingIt() throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("r1-restore"); + byte[] payload = "id,note\n1,legacy\n".getBytes(UTF_8); + String digest = LocalPersistentPublicationTestFixture.sha256(payload); + String operationToken = operationToken(fixture.operationId()); + String publishedFileName = "report--" + operationToken + ".csv"; + String stageFileName = "." + operationToken + ".part"; + Path artifact = fixture.root().resolve(publishedFileName); + Files.write(artifact, payload, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + Files.setPosixFilePermissions(artifact, PosixFilePermissions.fromString("rw-------")); + forceFile(artifact); + forceDirectory(fixture.root()); + LocalPublicationJournalRecord r1 = + LocalPublicationJournalRecord.sealed( + fixture.operationId(), + FilePublishRequestFingerprint.calculate(fixture.request("source-42")), + publishedFileName, + stageFileName, + payload.length, + 1, + 2, + digest, + 0) + .published( + LocalPersistentPublicationTestFixture.PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE.name()); + LocalPublicationJournal journal = new LocalPublicationJournal(fixture.root()); + journal.store(r1); + Path operationPath = journal.recordPath(fixture.operationId()); + byte[] operationEvidence = Files.readAllBytes(operationPath); + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + + FilePublishReceipt restored = + fixture + .provider( + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return fixture.fileId(); + }) + .publish(fixture.request("source-42"), sink -> producerCalls.incrementAndGet()); + + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + assertThat(restored.reference()) + .isEqualTo( + new PublishedFileReference( + "filepub:" + fixture.destination().destinationId().value() + ":" + operationToken)); + assertThat(restored.publishedFileName()).isEqualTo(publishedFileName); + assertThat(restored.version().value()).isEqualTo(digest); + assertThat(restored.publishedAt()) + .isEqualTo(LocalPersistentPublicationTestFixture.PUBLISHED_AT); + assertThat(restored.publicationGuarantee()) + .isEqualTo(PublicationGuarantee.UNIQUE_ATOMIC_CREATE); + assertThat(restored.durabilityGuarantee()).isEqualTo(DurabilityGuarantee.PROCESS_LOCAL_SYNC); + assertThat(operationPath).hasBinaryContent(operationEvidence); + assertThat(artifact).hasBinaryContent(payload); + assertThat(countRegularFiles(fixture.root().resolve(".ca-fileserver/manifests"))).isZero(); + assertThat(countRegularFiles(fixture.root().resolve(".ca-fileserver/references"))).isZero(); + } + + @ParameterizedTest + @ValueSource(strings = {"corrupt", "noncanonical"}) + void corruptOrNoncanonicalR1IsIndeterminateAndNeverPromoted(String invalidKind) + throws IOException { + LocalPersistentPublicationTestFixture fixture = fixture("r1-" + invalidKind); + R1Baseline baseline = prepareR1Baseline(fixture); + byte[] canonical = Files.readAllBytes(baseline.operationPath()); + byte[] invalid = + invalidKind.equals("corrupt") + ? "{\"corrupt\":true}".getBytes(UTF_8) + : new String(canonical, UTF_8).replaceFirst("\\{", "{ ").getBytes(UTF_8); + rewriteRecord(baseline.operationPath(), invalid); + + expectIndeterminateWithoutProducerOrFileId(fixture); + + assertThat(baseline.operationPath()).hasBinaryContent(invalid); + assertThat(baseline.artifact()).hasBinaryContent(baseline.payload()); + assertThat(countRegularFiles(fixture.root().resolve(".ca-fileserver/manifests"))).isZero(); + assertThat(countRegularFiles(fixture.root().resolve(".ca-fileserver/references"))).isZero(); + } + + private LocalPersistentPublicationTestFixture fixture(String name) throws IOException { + return LocalPersistentPublicationTestFixture.create( + temporaryDirectory.resolve(name + "-" + fixtureSequence.incrementAndGet())); + } + + private static PublishedBaseline publishBaseline(LocalPersistentPublicationTestFixture fixture) + throws IOException { + byte[] payload = "id,note\n1,'=cmd\n".getBytes(UTF_8); + LocalPersistentControlPlane controlPlane = fixture.controlPlane(); + FilePublishReceipt receipt = + fixture + .provider(controlPlane, fixture.payload(), () -> fixture.fileId()) + .publish( + fixture.request("source-42"), + sink -> + sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd"))))); + DurablePublicationRecord operation = + controlPlane.findOperation(fixture.operationId()).orElseThrow(); + Path operationPath = operationPath(fixture.root(), fixture.operationId()); + Path manifestPath = fileRecordPath(fixture.root(), "manifests", fixture.fileId()); + Path referencePath = fileRecordPath(fixture.root(), "references", fixture.fileId()); + Path dataPath = + fixture + .root() + .resolve("data") + .resolve(fixture.fileId().substring(0, 2)) + .resolve(operation.publishedFileName()); + assertThat(dataPath).hasBinaryContent(payload); + return new PublishedBaseline( + receipt, operation, payload, operationPath, manifestPath, referencePath, dataPath); + } + + private static FilePublishReceipt recoverWithoutProducer( + LocalPersistentPublicationTestFixture fixture) { + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + FilePublishReceipt receipt = + fixture + .provider( + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return "f".repeat(32); + }) + .publish( + fixture.request("source-42"), + sink -> { + producerCalls.incrementAndGet(); + throw new AssertionError("recovery must not invoke the producer"); + }); + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + return receipt; + } + + private static R1Baseline prepareR1Baseline(LocalPersistentPublicationTestFixture fixture) + throws IOException { + byte[] payload = "id,note\n1,legacy\n".getBytes(UTF_8); + String digest = LocalPersistentPublicationTestFixture.sha256(payload); + String operationToken = operationToken(fixture.operationId()); + String publishedFileName = "report--" + operationToken + ".csv"; + String stageFileName = "." + operationToken + ".part"; + Path artifact = fixture.root().resolve(publishedFileName); + Files.write(artifact, payload, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + Files.setPosixFilePermissions(artifact, PosixFilePermissions.fromString("rw-------")); + forceFile(artifact); + forceDirectory(fixture.root()); + LocalPublicationJournalRecord record = + LocalPublicationJournalRecord.sealed( + fixture.operationId(), + FilePublishRequestFingerprint.calculate(fixture.request("source-42")), + publishedFileName, + stageFileName, + payload.length, + 1, + 2, + digest, + 0) + .published( + LocalPersistentPublicationTestFixture.PUBLISHED_AT, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE.name()); + LocalPublicationJournal journal = new LocalPublicationJournal(fixture.root()); + journal.store(record); + return new R1Baseline(artifact, payload, journal.recordPath(fixture.operationId())); + } + + private static DurablePublicationRecord recoverAndRequireQuarantine( + LocalPersistentPublicationTestFixture fixture) { + expectIndeterminateWithoutProducerOrFileId(fixture); + DurablePublicationRecord operation = + fixture.controlPlane().findOperation(fixture.operationId()).orElseThrow(); + assertThat(operation.state()).isEqualTo(DurablePublicationRecord.State.QUARANTINED); + return operation; + } + + private static void expectIndeterminateWithoutProducerOrFileId( + LocalPersistentPublicationTestFixture fixture) { + AtomicInteger producerCalls = new AtomicInteger(); + AtomicInteger fileIdCalls = new AtomicInteger(); + assertThatThrownBy( + () -> + fixture + .provider( + fixture.controlPlane(), + fixture.payload(), + () -> { + fileIdCalls.incrementAndGet(); + return "f".repeat(32); + }) + .publish(fixture.request("source-42"), sink -> producerCalls.incrementAndGet())) + .isInstanceOfSatisfying( + FilePublicationException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(FilePublicationException.Reason.PUBLISH_INDETERMINATE)); + assertThat(producerCalls).hasValue(0); + assertThat(fileIdCalls).hasValue(0); + } + + private static void prepareAtState( + PublishedBaseline baseline, DurablePublicationRecord.State state) throws IOException { + switch (state) { + case SEALED, DATA_PUBLISHED -> { + removeControlRecord(baseline.referencePath()); + removeControlRecord(baseline.manifestPath()); + } + case MANIFEST_PUBLISHED -> removeControlRecord(baseline.referencePath()); + case REFERENCE_PUBLISHED -> { + // Both immutable metadata records remain authority for this state. + } + default -> throw new IllegalArgumentException("unsupported non-terminal fixture state"); + } + rewriteOperation(baseline.operationPath(), atState(baseline.operation(), state)); + } + + private static DurablePublicationRecord atState( + DurablePublicationRecord terminal, DurablePublicationRecord.State state) { + if (state == DurablePublicationRecord.State.WRITING + || state == DurablePublicationRecord.State.PUBLISHED + || state == DurablePublicationRecord.State.QUARANTINED) { + throw new IllegalArgumentException("fixture supports only sealed non-terminal states"); + } + boolean hasManifest = + state == DurablePublicationRecord.State.MANIFEST_PUBLISHED + || state == DurablePublicationRecord.State.REFERENCE_PUBLISHED; + boolean hasReference = state == DurablePublicationRecord.State.REFERENCE_PUBLISHED; + return new DurablePublicationRecord( + DurablePublicationRecord.CURRENT_SCHEMA_VERSION, + state.minimumRevision(), + state, + terminal.operationId(), + terminal.requestFingerprint(), + terminal.effectivePolicyRevision(), + terminal.effectivePolicyDigest(), + terminal.destinationId(), + terminal.providerId(), + terminal.fileId(), + terminal.routeToken(), + terminal.publishedFileName(), + terminal.stageFileName(), + terminal.byteSize(), + terminal.rowCount(), + terminal.columnCount(), + terminal.sha256(), + terminal.formulaMitigatedCount(), + hasManifest ? terminal.manifestDigest() : "", + hasReference ? terminal.referenceDigest() : "", + terminal.createdAt(), + terminal.sealedAt(), + null, + "", + ""); + } + + private static DurablePublicationRecord writingRecord( + LocalPersistentPublicationTestFixture fixture) { + return new DurablePublicationRecord( + DurablePublicationRecord.CURRENT_SCHEMA_VERSION, + DurablePublicationRecord.State.WRITING.minimumRevision(), + DurablePublicationRecord.State.WRITING, + fixture.operationId(), + FilePublishRequestFingerprint.calculate(fixture.request("source-42")), + fixture.destination().effectivePolicyRevision(), + fixture.destination().effectivePolicyDigest(), + fixture.destination().destinationId().value(), + fixture.destination().providerId(), + fixture.fileId(), + fixture.destination().routeToken(), + LocalPersistentRecoveryVerifier.generatedFileName(fixture.fileId()), + LocalPersistentPayloadOperations.stageFileName(fixture.operationId()), + 0, + 0, + 0, + "", + 0, + "", + "", + LocalPersistentPublicationTestFixture.PUBLISHED_AT, + null, + null, + "", + ""); + } + + private static DurablePublicationRecord unsealedQuarantined( + DurablePublicationRecord writing, String failureCode) { + return new DurablePublicationRecord( + writing.schemaVersion(), + writing.stateRevision() + 1, + DurablePublicationRecord.State.QUARANTINED, + writing.operationId(), + writing.requestFingerprint(), + writing.effectivePolicyRevision(), + writing.effectivePolicyDigest(), + writing.destinationId(), + writing.providerId(), + writing.fileId(), + writing.routeToken(), + writing.publishedFileName(), + writing.stageFileName(), + 0, + 0, + 0, + "", + 0, + "", + "", + writing.createdAt(), + null, + null, + failureCode, + ""); + } + + private static DurablePublicationRecord publishedWithReceipt( + DurablePublicationRecord published, String receiptSnapshot) { + return new DurablePublicationRecord( + published.schemaVersion(), + published.stateRevision(), + published.state(), + published.operationId(), + published.requestFingerprint(), + published.effectivePolicyRevision(), + published.effectivePolicyDigest(), + published.destinationId(), + published.providerId(), + published.fileId(), + published.routeToken(), + published.publishedFileName(), + published.stageFileName(), + published.byteSize(), + published.rowCount(), + published.columnCount(), + published.sha256(), + published.formulaMitigatedCount(), + published.manifestDigest(), + published.referenceDigest(), + published.createdAt(), + published.sealedAt(), + published.publishedAt(), + published.lastFailureCode(), + receiptSnapshot); + } + + private static FilePublishReceipt receiptWithFormat( + FilePublishReceipt receipt, String formatProfileId) { + return new FilePublishReceipt( + receipt.operationId(), + receipt.reference(), + receipt.destinationId(), + receipt.publishedFileName(), + receipt.version(), + formatProfileId, + receipt.mediaType(), + receipt.charset(), + receipt.byteSize(), + receipt.dataRowCount(), + receipt.columnCount(), + receipt.sha256(), + receipt.publishedAt(), + receipt.publicationGuarantee(), + receipt.durabilityGuarantee(), + receipt.formulaMitigatedCount()); + } + + private static PrivateFileManifest manifestWithSchemaId( + PrivateFileManifest manifest, String schemaId) { + return new PrivateFileManifest( + manifest.schemaVersion(), + manifest.operationId(), + manifest.fileId(), + manifest.providerId(), + manifest.fileReference(), + manifest.requestFingerprint(), + manifest.destinationId(), + schemaId, + manifest.exportSchemaVersion(), + manifest.schemaDigest(), + manifest.formatProfileId(), + manifest.formatPolicyDigest(), + manifest.effectivePolicyRevision(), + manifest.effectivePolicyDigest(), + manifest.publishedFileName(), + manifest.fileVersion(), + manifest.mediaType(), + manifest.charset(), + manifest.byteSize(), + manifest.rowCount(), + manifest.columnCount(), + manifest.sha256(), + manifest.formulaMitigatedCount(), + manifest.publicationGuarantee(), + manifest.durabilityGuarantee(), + manifest.internalLocator(), + manifest.createdAt(), + manifest.publishedAt()); + } + + private static PublishedReferenceRecord referenceWithMediaType( + PublishedReferenceRecord reference, String mediaType) { + return new PublishedReferenceRecord( + reference.schemaVersion(), + reference.fileId(), + reference.routeToken(), + reference.fileReference(), + reference.operationId(), + reference.fileVersion(), + reference.manifestDigest(), + reference.internalLocator(), + reference.destinationId(), + reference.providerId(), + reference.publishedFileName(), + mediaType, + reference.charset(), + reference.byteSize(), + reference.sha256(), + reference.publishedAt()); + } + + private static Path stagePath( + LocalPersistentPublicationTestFixture fixture, DurablePublicationRecord operation) { + String operationDigest = + LocalPersistentPublicationTestFixture.sha256(fixture.operationId().getBytes(UTF_8)); + return fixture + .root() + .resolve(".ca-fileserver/staging") + .resolve(operationDigest.substring(0, 2)) + .resolve(operation.stageFileName()); + } + + private static Path operationPath(Path root, String operationId) { + String digest = LocalPersistentPublicationTestFixture.sha256(operationId.getBytes(UTF_8)); + return root.resolve(".ca-fileserver/operations") + .resolve(digest.substring(0, 2)) + .resolve(digest + ".json"); + } + + private static Path fileRecordPath(Path root, String kind, String fileId) { + return root.resolve(".ca-fileserver") + .resolve(kind) + .resolve(fileId.substring(0, 2)) + .resolve(fileId + ".json"); + } + + private static void rewriteOperation(Path target, DurablePublicationRecord record) + throws IOException { + rewriteRecord(target, new FileserverControlRecordCodec().encodeOperation(record)); + } + + private static void rewriteRecord(Path target, byte[] canonicalRecord) throws IOException { + Files.write( + target, canonicalRecord, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + forceFile(target); + forceDirectory(target.getParent()); + } + + private static void removeControlRecord(Path target) throws IOException { + Files.delete(target); + forceDirectory(target.getParent()); + } + + private static void forceFile(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { + channel.force(true); + } + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static long countRegularFiles(Path directory) throws IOException { + try (var paths = Files.walk(directory)) { + return paths.filter(Files::isRegularFile).count(); + } + } + + private static String operationToken(String operationId) { + return LocalPersistentPublicationTestFixture.sha256(operationId.getBytes(UTF_8)) + .substring(0, 24); + } + + private static final class PublishedBaseline { + + private final FilePublishReceipt receipt; + private final DurablePublicationRecord operation; + private final byte[] payload; + private final Path operationPath; + private final Path manifestPath; + private final Path referencePath; + private final Path dataPath; + + private PublishedBaseline( + FilePublishReceipt receipt, + DurablePublicationRecord operation, + byte[] payload, + Path operationPath, + Path manifestPath, + Path referencePath, + Path dataPath) { + this.receipt = receipt; + this.operation = operation; + this.payload = payload.clone(); + this.operationPath = operationPath; + this.manifestPath = manifestPath; + this.referencePath = referencePath; + this.dataPath = dataPath; + } + + private FilePublishReceipt receipt() { + return receipt; + } + + private DurablePublicationRecord operation() { + return operation; + } + + private byte[] payload() { + return payload.clone(); + } + + private Path operationPath() { + return operationPath; + } + + private Path manifestPath() { + return manifestPath; + } + + private Path referencePath() { + return referencePath; + } + + private Path dataPath() { + return dataPath; + } + } + + private static final class R1Baseline { + + private final Path artifact; + private final byte[] payload; + private final Path operationPath; + + private R1Baseline(Path artifact, byte[] payload, Path operationPath) { + this.artifact = artifact; + this.payload = payload.clone(); + this.operationPath = operationPath; + } + + private Path artifact() { + return artifact; + } + + private byte[] payload() { + return payload.clone(); + } + + private Path operationPath() { + return operationPath; + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java new file mode 100644 index 0000000..a2c215e --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestorTest.java @@ -0,0 +1,769 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import dev.caskeleton.application.filepublication.FileDestinationId; +import dev.caskeleton.application.filepublication.FilePublishReceipt.DurabilityGuarantee; +import dev.caskeleton.application.filepublication.FilePublishReceipt.PublicationGuarantee; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +@EnabledOnOs(OS.LINUX) +class LocalPersistentRootAttestorTest { + + private static final String SENTINEL_NAME = ".ca-fileserver-volume"; + private static final byte[] SENTINEL_CONTENT = "fileserver-r2-test-volume\n".getBytes(UTF_8); + private static final Set INTERNAL_DIRECTORIES = + Set.of( + ".ca-fileserver", + "data", + ".ca-fileserver/staging", + ".ca-fileserver/operations", + ".ca-fileserver/manifests", + ".ca-fileserver/references", + ".ca-fileserver/quarantine", + ".ca-fileserver/probe"); + + @TempDir Path tempDirectory; + + @Test + void attestsOwnerModeStoreSentinelSecureDirectoryAndSyncPrimitives() throws IOException { + RootFixture fixture = attestedRoot("successful-root"); + PosixFileAttributes rootAttributes = + Files.readAttributes(fixture.root(), PosixFileAttributes.class); + + LocalPersistentRootEvidence evidence = + new LocalPersistentRootAttestor().attest(destinationFor(fixture)); + + assertThat(evidence.root()).isEqualTo(fixture.root().toRealPath()); + assertThat(evidence.rootFileKey()).isEqualTo(rootAttributes.fileKey().toString()); + assertThat(evidence.fileStoreName()).isEqualTo(fixture.fileStoreName()); + assertThat(evidence.fileStoreType()).isEqualTo(fixture.fileStoreType()); + assertThat(evidence.mountSentinelSha256()).isEqualTo(fixture.sentinelSha256()); + assertThat(evidence.secureDirectoryStream()).isTrue(); + assertThat(evidence.directorySync()).isTrue(); + assertThat(evidence.exclusiveHardLink()).isTrue(); + assertThat(evidence.criticalDirectoryFileKeys()).containsOnlyKeys(INTERNAL_DIRECTORIES); + assertThat(fixture.root().resolve(".ca-fileserver").resolve("data")).doesNotExist(); + + for (String relativeDirectory : INTERNAL_DIRECTORIES) { + Path directory = fixture.root().resolve(relativeDirectory); + assertThat(directory).isDirectory(); + assertThat(Files.getPosixFilePermissions(directory)) + .containsExactlyInAnyOrderElementsOf(PosixFilePermissions.fromString("rwx------")); + } + try (var probeArtifacts = + Files.list(fixture.root().resolve(".ca-fileserver").resolve("probe"))) { + assertThat(probeArtifacts).isEmpty(); + } + assertThatCode(() -> new LocalPersistentRootAttestor().verifyIdentity(evidence)) + .doesNotThrowAnyException(); + } + + @Test + void evidenceDefensivelyCopiesIdentitiesAndMaximumRootPermissions() throws IOException { + RootFixture fixture = attestedRoot("immutable-evidence"); + LocalPersistentRootEvidence evidence = + new LocalPersistentRootAttestor().attest(destinationFor(fixture)); + + assertThat(evidence.expectedOwner()).isEqualTo(fixture.owner()); + assertThat(evidence.maximumRootPermissions()) + .containsExactlyInAnyOrderElementsOf(fixture.maximumPermissions()); + assertThatThrownBy(() -> evidence.criticalDirectoryFileKeys().put("mutable", "identity")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> evidence.maximumRootPermissions().add(PosixFilePermission.GROUP_WRITE)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rejectsMissingRoot() throws IOException { + RootFixture fixture = attestedRoot("missing-root-reference"); + Path missingRoot = tempDirectory.resolve("missing-root").toAbsolutePath().normalize(); + + assertAttestationFailure(destinationFor(fixture, missingRoot)) + .hasMessageContaining("root") + .hasMessageContaining("exist"); + assertThat(missingRoot.resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsRelativeRootBeforeAttestation() throws IOException { + RootFixture fixture = attestedRoot("relative-root-reference"); + + assertThatThrownBy(() -> destinationFor(fixture, Path.of("relative/root"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("absolute"); + } + + @Test + void rejectsNonNormalizedRootBeforeAttestation() throws IOException { + RootFixture fixture = attestedRoot("non-normalized-root-reference"); + Path nonNormalized = + tempDirectory.resolve("unused").resolve("..").resolve("non-normalized-root"); + + assertThat(nonNormalized).isNotEqualTo(nonNormalized.normalize()); + assertThatThrownBy(() -> destinationFor(fixture, nonNormalized)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("normalized"); + } + + @Test + void rejectsSymbolicLinkRoot() throws IOException { + RootFixture fixture = attestedRoot("real-root"); + Path symbolicRoot = tempDirectory.resolve("symbolic-root").toAbsolutePath().normalize(); + Files.createSymbolicLink(symbolicRoot, fixture.root()); + + assertAttestationFailure(destinationFor(fixture, symbolicRoot)) + .hasMessageContaining("symbolic link"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsSymbolicLinkInAnyExistingRootAncestor() throws IOException { + Path realParent = tempDirectory.resolve("real-parent"); + Files.createDirectory(realParent); + Path root = realParent.resolve("root"); + Files.createDirectory(root, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + createSentinel(root); + RootFixture fixture = fixtureFor(root); + Path symbolicParent = tempDirectory.resolve("symbolic-parent"); + Files.createSymbolicLink(symbolicParent, realParent); + Path configuredRoot = symbolicParent.resolve("root").toAbsolutePath().normalize(); + + assertAttestationFailure(destinationFor(fixture, configuredRoot)) + .hasMessageContaining("symbolic link") + .hasMessageContaining("ancestor"); + assertThat(root.resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsOwnerMismatchBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("owner-mismatch"); + CompiledFileDestination destination = + destinationFor( + fixture.root(), + "definitely-not-" + fixture.owner(), + fixture.maximumPermissions(), + fixture.fileStoreName(), + fixture.fileStoreType(), + fixture.sentinelSha256()); + + assertAttestationFailure(destination).hasMessageContaining("owner"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsGroupOrWorldWritableRootEvenWhenConfiguredModeWasPreviouslyValid() + throws IOException { + RootFixture fixture = attestedRoot("group-writable"); + CompiledFileDestination destination = destinationFor(fixture); + Set writable = new HashSet<>(fixture.maximumPermissions()); + writable.add(PosixFilePermission.GROUP_WRITE); + Files.setPosixFilePermissions(fixture.root(), writable); + + assertAttestationFailure(destination).hasMessageContaining("group/world writable"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsRootPermissionsBroaderThanConfiguredMaximum() throws IOException { + RootFixture fixture = attestedRoot("broader-mode"); + Set actualPermissions = PosixFilePermissions.fromString("rwxr-x---"); + Files.setPosixFilePermissions(fixture.root(), actualPermissions); + CompiledFileDestination destination = + destinationFor( + fixture.root(), + fixture.owner(), + ownerOnlyDirectoryMode(), + fixture.fileStoreName(), + fixture.fileStoreType(), + fixture.sentinelSha256()); + + assertAttestationFailure(destination).hasMessageContaining("maximum"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsFileStoreNameMismatchBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("store-name-mismatch"); + CompiledFileDestination destination = + destinationFor( + fixture.root(), + fixture.owner(), + fixture.maximumPermissions(), + fixture.fileStoreName() + "-different", + fixture.fileStoreType(), + fixture.sentinelSha256()); + + assertAttestationFailure(destination).hasMessageContaining("FileStore name"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsFileStoreTypeMismatchBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("store-type-mismatch"); + CompiledFileDestination destination = + destinationFor( + fixture.root(), + fixture.owner(), + fixture.maximumPermissions(), + fixture.fileStoreName(), + fixture.fileStoreType() + "-different", + fixture.sentinelSha256()); + + assertAttestationFailure(destination).hasMessageContaining("FileStore type"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsMissingMountSentinelBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("missing-sentinel"); + Files.delete(fixture.root().resolve(SENTINEL_NAME)); + + assertAttestationFailure(destinationFor(fixture)).hasMessageContaining("sentinel"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsSymbolicLinkMountSentinelBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("symbolic-sentinel"); + Path sentinel = fixture.root().resolve(SENTINEL_NAME); + Files.delete(sentinel); + Path target = tempDirectory.resolve("sentinel-target"); + Files.write(target, SENTINEL_CONTENT); + Files.createSymbolicLink(sentinel, target); + + assertAttestationFailure(destinationFor(fixture)) + .hasMessageContaining("sentinel") + .hasMessageContaining("symbolic link"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsNonRegularMountSentinelBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("non-regular-sentinel"); + Path sentinel = fixture.root().resolve(SENTINEL_NAME); + Files.delete(sentinel); + Files.createDirectory(sentinel); + + assertAttestationFailure(destinationFor(fixture)) + .hasMessageContaining("sentinel") + .hasMessageContaining("regular"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsMountSentinelDigestMismatchBeforeCreatingInternalArtifacts() throws IOException { + RootFixture fixture = attestedRoot("digest-mismatch"); + Files.writeString(fixture.root().resolve(SENTINEL_NAME), "different-volume\n"); + + assertAttestationFailure(destinationFor(fixture)).hasMessageContaining("SHA-256"); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsExistingRootLevelDataDirectorySymbolicLink() throws IOException { + RootFixture fixture = attestedRoot("internal-symbolic-link"); + Path control = fixture.root().resolve(".ca-fileserver"); + Files.createDirectory(control, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + for (String name : + new String[] {"staging", "operations", "manifests", "references", "quarantine", "probe"}) { + Files.createDirectory( + control.resolve(name), PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + } + Path externalData = tempDirectory.resolve("external-data"); + Files.createDirectory(externalData); + Files.createSymbolicLink(fixture.root().resolve("data"), externalData); + + assertAttestationFailure(destinationFor(fixture)) + .hasMessageContaining("data") + .hasMessageContaining("symbolic link"); + } + + @Test + void rejectsExistingControlChildBeforeCreatingRootLevelData() throws IOException { + RootFixture fixture = attestedRoot("existing-control-child-symbolic-link"); + Path control = fixture.root().resolve(".ca-fileserver"); + Files.createDirectory(control, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + Path externalStaging = tempDirectory.resolve("external-staging"); + Files.createDirectory(externalStaging); + Files.createSymbolicLink(control.resolve("staging"), externalStaging); + + assertAttestationFailure(destinationFor(fixture)) + .hasMessageContaining("staging") + .hasMessageContaining("symbolic link"); + assertThat(fixture.root().resolve("data")).doesNotExist(); + assertThat(control).isDirectory(); + assertThat(control.resolve("staging")).isSymbolicLink(); + } + + @Test + void removesNewHierarchyAfterInternalDirectoryFileStoreMismatch() throws IOException { + RootFixture fixture = attestedRoot("internal-store-mismatch"); + LocalPersistentRootAttestor.FileStoreProbe systemProbe = + LocalPersistentRootAttestor.systemFileStoreProbe(); + LocalPersistentRootAttestor.FileStoreProbe mismatchingProbe = + path -> { + LocalPersistentRootAttestor.FileStoreIdentity actual = systemProbe.inspect(path); + if (path.endsWith("staging")) { + return new LocalPersistentRootAttestor.FileStoreIdentity( + actual.name() + "-different", actual.type(), actual.identity() + "-different"); + } + return actual; + }; + + LocalPersistentRootAttestor attestor = + new LocalPersistentRootAttestor( + LocalPersistentRootAttestor.systemCapabilityOperations(), mismatchingProbe); + + assertAttestationFailure(attestor, destinationFor(fixture)) + .hasMessageContaining("staging") + .hasMessageContaining("FileStore"); + assertThat(fixture.root().resolve("data")).doesNotExist(); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void preservesPreexistingRootLevelDataAfterFileStoreMismatch() throws IOException { + RootFixture fixture = attestedRoot("preexisting-data-store-mismatch"); + Path data = fixture.root().resolve("data"); + Files.createDirectory(data, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + LocalPersistentRootAttestor.FileStoreProbe systemProbe = + LocalPersistentRootAttestor.systemFileStoreProbe(); + LocalPersistentRootAttestor.FileStoreProbe mismatchingProbe = + path -> { + LocalPersistentRootAttestor.FileStoreIdentity actual = systemProbe.inspect(path); + if (path.equals(data)) { + return new LocalPersistentRootAttestor.FileStoreIdentity( + actual.name() + "-different", actual.type(), actual.identity() + "-different"); + } + return actual; + }; + LocalPersistentRootAttestor attestor = + new LocalPersistentRootAttestor( + LocalPersistentRootAttestor.systemCapabilityOperations(), mismatchingProbe); + + assertAttestationFailure(attestor, destinationFor(fixture)) + .hasMessageContaining("data") + .hasMessageContaining("FileStore"); + assertThat(data).isDirectory(); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rollbackNeverDeletesAReplacementAtACreatedDirectoryPath() throws IOException { + RootFixture fixture = attestedRoot("rollback-replacement"); + Path control = fixture.root().resolve(".ca-fileserver"); + Path staging = control.resolve("staging"); + Path movedCreatedStaging = control.resolve("attestor-created-staging"); + AtomicBoolean replaced = new AtomicBoolean(); + LocalPersistentRootAttestor.FileStoreProbe systemProbe = + LocalPersistentRootAttestor.systemFileStoreProbe(); + LocalPersistentRootAttestor.FileStoreProbe replacingMismatchingProbe = + path -> { + LocalPersistentRootAttestor.FileStoreIdentity actual = systemProbe.inspect(path); + if (path.equals(staging) && replaced.compareAndSet(false, true)) { + Files.move(staging, movedCreatedStaging); + Files.createDirectory( + staging, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + actual = systemProbe.inspect(staging); + return new LocalPersistentRootAttestor.FileStoreIdentity( + actual.name() + "-different", actual.type(), actual.identity() + "-different"); + } + return actual; + }; + LocalPersistentRootAttestor attestor = + new LocalPersistentRootAttestor( + LocalPersistentRootAttestor.systemCapabilityOperations(), replacingMismatchingProbe); + + assertAttestationFailure(attestor, destinationFor(fixture)) + .hasMessageContaining("staging") + .hasMessageContaining("FileStore"); + assertThat(replaced).isTrue(); + assertThat(staging).isDirectory(); + assertThat(movedCreatedStaging).isDirectory(); + } + + @Test + void rejectsParentIdentityChangeAcrossRootLevelDataCreationAndRollsBack() throws IOException { + RootFixture fixture = attestedRoot("parent-identity-race"); + LocalPersistentRootAttestor.ParentIdentityProbe systemProbe = + LocalPersistentRootAttestor.systemParentIdentityProbe(); + AtomicInteger rootInspections = new AtomicInteger(); + LocalPersistentRootAttestor.ParentIdentityProbe changingProbe = + parent -> { + String identity = systemProbe.inspect(parent); + if (parent.equals(fixture.root()) && rootInspections.incrementAndGet() == 3) { + return identity + "-replaced"; + } + return identity; + }; + LocalPersistentRootAttestor attestor = + new LocalPersistentRootAttestor( + LocalPersistentRootAttestor.systemCapabilityOperations(), + LocalPersistentRootAttestor.systemFileStoreProbe(), + changingProbe); + + assertAttestationFailure(attestor, destinationFor(fixture)) + .hasMessageContaining("parent") + .hasMessageContaining("identity"); + assertThat(fixture.root().resolve("data")).doesNotExist(); + assertThat(fixture.root().resolve(".ca-fileserver")).doesNotExist(); + } + + @Test + void rejectsUnavailableSecureDirectoryStreamWithoutDowngrade() throws IOException { + RootFixture fixture = attestedRoot("secure-directory-unavailable"); + LocalPersistentRootAttestor.CapabilityOperations capabilities = + new DelegatingCapabilities() { + @Override + public SecureDirectoryStream openSecureDirectory(Path directory) + throws IOException { + throw new IOException("SecureDirectoryStream unavailable"); + } + }; + + assertAttestationFailure( + new LocalPersistentRootAttestor( + capabilities, LocalPersistentRootAttestor.systemFileStoreProbe()), + destinationFor(fixture)) + .hasMessageContaining("SecureDirectoryStream"); + } + + @Test + void rejectsUnavailableExclusiveHardLinkWithoutDowngrade() throws IOException { + RootFixture fixture = attestedRoot("hard-link-unavailable"); + LocalPersistentRootAttestor.CapabilityOperations capabilities = + new DelegatingCapabilities() { + @Override + public void createHardLink(Path link, Path existing) throws IOException { + throw new IOException("exclusive hard-link unavailable"); + } + }; + + assertAttestationFailure( + new LocalPersistentRootAttestor( + capabilities, LocalPersistentRootAttestor.systemFileStoreProbe()), + destinationFor(fixture)) + .hasMessageContaining("hard-link"); + } + + @Test + void preservesPrimaryHardLinkFailureWhenCleanupForceThrowsRuntimeException() throws IOException { + RootFixture fixture = attestedRoot("hard-link-cleanup-runtime"); + AtomicBoolean cleanupForceAttempted = new AtomicBoolean(); + LocalPersistentRootAttestor.CapabilityOperations capabilities = + new DelegatingCapabilities() { + @Override + public void createHardLink(Path link, Path existing) throws IOException { + throw new IOException("primary hard-link failure"); + } + + @Override + public void forceDirectory(Path directory) throws IOException { + if (directory.endsWith("probe")) { + cleanupForceAttempted.set(true); + throw new IllegalStateException("cleanup force runtime failure"); + } + super.forceDirectory(directory); + } + }; + LocalPersistentRootAttestor attestor = + new LocalPersistentRootAttestor( + capabilities, LocalPersistentRootAttestor.systemFileStoreProbe()); + + Throwable failure = catchThrowable(() -> attestor.attest(destinationFor(fixture))); + + assertThat(failure) + .isInstanceOf(LocalPersistentRootAttestor.LocalPersistentRootAttestationException.class) + .hasMessageContaining("primary hard-link failure"); + assertThat(failure.getCause()) + .isInstanceOf(IOException.class) + .hasMessageContaining("primary hard-link failure"); + assertThat(failure.getCause().getSuppressed()) + .singleElement() + .satisfies( + cleanupFailure -> + assertThat(cleanupFailure) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cleanup force runtime failure")); + assertThat(cleanupForceAttempted).isTrue(); + } + + @Test + void rejectsUnavailableDirectoryForceWithoutDowngrade() throws IOException { + RootFixture fixture = attestedRoot("directory-force-unavailable"); + AtomicBoolean probeDirectoryForceAttempted = new AtomicBoolean(); + LocalPersistentRootAttestor.CapabilityOperations capabilities = + new DelegatingCapabilities() { + @Override + public void forceDirectory(Path directory) throws IOException { + if (directory.endsWith("probe")) { + probeDirectoryForceAttempted.set(true); + throw new IOException("probe directory force unavailable"); + } + super.forceDirectory(directory); + } + }; + + assertAttestationFailure( + new LocalPersistentRootAttestor( + capabilities, LocalPersistentRootAttestor.systemFileStoreProbe()), + destinationFor(fixture)) + .hasMessageContaining("directory force"); + assertThat(probeDirectoryForceAttempted).isTrue(); + try (var probeArtifacts = + Files.list(fixture.root().resolve(".ca-fileserver").resolve("probe"))) { + assertThat(probeArtifacts).isEmpty(); + } + } + + @Test + void rejectsUnavailableFileForceWithoutDowngrade() throws IOException { + RootFixture fixture = attestedRoot("file-force-unavailable"); + AtomicBoolean fileForceAttempted = new AtomicBoolean(); + LocalPersistentRootAttestor.CapabilityOperations capabilities = + new DelegatingCapabilities() { + @Override + public void forceFile(FileChannel channel) throws IOException { + fileForceAttempted.set(true); + throw new IOException("file force unavailable"); + } + }; + + assertAttestationFailure( + new LocalPersistentRootAttestor( + capabilities, LocalPersistentRootAttestor.systemFileStoreProbe()), + destinationFor(fixture)) + .hasMessageContaining("file force"); + assertThat(fileForceAttempted).isTrue(); + try (var probeArtifacts = + Files.list(fixture.root().resolve(".ca-fileserver").resolve("probe"))) { + assertThat(probeArtifacts).isEmpty(); + } + } + + @Test + void verifyIdentityRejectsChangedMountSentinel() throws IOException { + RootFixture fixture = attestedRoot("verify-sentinel"); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destinationFor(fixture)); + Files.writeString(fixture.root().resolve(SENTINEL_NAME), "changed-volume\n"); + + assertThatThrownBy(() -> attestor.verifyIdentity(evidence)) + .isInstanceOf(LocalPersistentRootAttestor.LocalPersistentRootAttestationException.class) + .hasMessageContaining("sentinel"); + } + + @Test + void verifyIdentityRejectsRootModeDriftOnTheSameInode() throws IOException { + RootFixture fixture = attestedRoot("verify-root-mode"); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destinationFor(fixture)); + String originalFileKey = + Files.readAttributes(fixture.root(), PosixFileAttributes.class).fileKey().toString(); + Set driftedPermissions = + new HashSet<>(Files.getPosixFilePermissions(fixture.root())); + driftedPermissions.add(PosixFilePermission.GROUP_WRITE); + Files.setPosixFilePermissions(fixture.root(), driftedPermissions); + + assertThat(Files.readAttributes(fixture.root(), PosixFileAttributes.class).fileKey().toString()) + .isEqualTo(originalFileKey); + assertThatThrownBy(() -> attestor.verifyIdentity(evidence)) + .isInstanceOf(LocalPersistentRootAttestor.LocalPersistentRootAttestationException.class) + .hasMessageContaining("group/world writable"); + } + + @Test + void verifyIdentityRejectsReplacedCriticalInternalDirectory() throws IOException { + RootFixture fixture = attestedRoot("verify-internal-directory"); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destinationFor(fixture)); + Path data = fixture.root().resolve("data"); + assertThat(data).isDirectory(); + Files.move(data, data.resolveSibling("original-data")); + Files.createDirectory(data, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + + assertThatThrownBy(() -> attestor.verifyIdentity(evidence)) + .isInstanceOf(LocalPersistentRootAttestor.LocalPersistentRootAttestationException.class) + .hasMessageContaining("data") + .hasMessageContaining("identity"); + } + + @Test + void verifyIdentityRejectsReplacedRoot() throws IOException { + RootFixture fixture = attestedRoot("verify-root"); + LocalPersistentRootAttestor attestor = new LocalPersistentRootAttestor(); + LocalPersistentRootEvidence evidence = attestor.attest(destinationFor(fixture)); + Path movedRoot = fixture.root().resolveSibling("original-root"); + Files.move(fixture.root(), movedRoot); + Files.createDirectory( + fixture.root(), PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + createSentinel(fixture.root()); + + assertThatThrownBy(() -> attestor.verifyIdentity(evidence)) + .isInstanceOf(LocalPersistentRootAttestor.LocalPersistentRootAttestationException.class) + .hasMessageContaining("root") + .hasMessageContaining("identity"); + } + + private RootFixture attestedRoot(String name) throws IOException { + Path root = tempDirectory.resolve(name).toAbsolutePath().normalize(); + Files.createDirectory(root, PosixFilePermissions.asFileAttribute(ownerOnlyDirectoryMode())); + createSentinel(root); + return fixtureFor(root); + } + + private static void createSentinel(Path root) throws IOException { + Path sentinel = root.resolve(SENTINEL_NAME); + Files.createFile( + sentinel, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + Files.write(sentinel, SENTINEL_CONTENT); + } + + private static RootFixture fixtureFor(Path root) throws IOException { + PosixFileAttributes attributes = Files.readAttributes(root, PosixFileAttributes.class); + FileStore store = Files.getFileStore(root); + return new RootFixture( + root, + attributes.owner().getName(), + Set.copyOf(attributes.permissions()), + store.name(), + store.type(), + sha256(root.resolve(SENTINEL_NAME))); + } + + private static CompiledFileDestination destinationFor(RootFixture fixture) { + return destinationFor(fixture, fixture.root()); + } + + private static CompiledFileDestination destinationFor(RootFixture fixture, Path root) { + return destinationFor( + root, + fixture.owner(), + fixture.maximumPermissions(), + fixture.fileStoreName(), + fixture.fileStoreType(), + fixture.sentinelSha256()); + } + + private static CompiledFileDestination destinationFor( + Path root, + String expectedOwner, + Set maximumPermissions, + String expectedFileStoreName, + String expectedFileStoreType, + String expectedSentinelSha256) { + return new CompiledFileDestination( + new FileDestinationId("local-export"), + "local-primary", + root, + 1_000, + 1_048_576, + expectedFileStoreName, + expectedFileStoreType, + SENTINEL_NAME, + expectedSentinelSha256, + expectedOwner, + mode(maximumPermissions), + maximumPermissions, + PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + } + + private static org.assertj.core.api.AbstractThrowableAssert + assertAttestationFailure(CompiledFileDestination destination) { + return assertAttestationFailure(new LocalPersistentRootAttestor(), destination); + } + + private static org.assertj.core.api.AbstractThrowableAssert + assertAttestationFailure( + LocalPersistentRootAttestor attestor, CompiledFileDestination destination) { + return assertThatThrownBy(() -> attestor.attest(destination)) + .isInstanceOf(LocalPersistentRootAttestor.LocalPersistentRootAttestationException.class); + } + + private static String sha256(Path file) throws IOException { + try { + return java.util.HexFormat.of() + .formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 must be available", exception); + } + } + + private static Set ownerOnlyDirectoryMode() { + return PosixFilePermissions.fromString("rwx------"); + } + + private static String mode(Set permissions) { + int value = 0; + value |= permissions.contains(PosixFilePermission.OWNER_READ) ? 0400 : 0; + value |= permissions.contains(PosixFilePermission.OWNER_WRITE) ? 0200 : 0; + value |= permissions.contains(PosixFilePermission.OWNER_EXECUTE) ? 0100 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_READ) ? 0040 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_WRITE) ? 0020 : 0; + value |= permissions.contains(PosixFilePermission.GROUP_EXECUTE) ? 0010 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_READ) ? 0004 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_WRITE) ? 0002 : 0; + value |= permissions.contains(PosixFilePermission.OTHERS_EXECUTE) ? 0001 : 0; + return String.format("%04o", value); + } + + private record RootFixture( + Path root, + String owner, + Set maximumPermissions, + String fileStoreName, + String fileStoreType, + String sentinelSha256) {} + + private abstract static class DelegatingCapabilities + implements LocalPersistentRootAttestor.CapabilityOperations { + + private final LocalPersistentRootAttestor.CapabilityOperations delegate = + LocalPersistentRootAttestor.systemCapabilityOperations(); + + @Override + public SecureDirectoryStream openSecureDirectory(Path directory) throws IOException { + return delegate.openSecureDirectory(directory); + } + + @Override + public void forceFile(FileChannel channel) throws IOException { + delegate.forceFile(channel); + } + + @Override + public void createHardLink(Path link, Path existing) throws IOException { + delegate.createHardLink(link, existing); + } + + @Override + public void forceDirectory(Path directory) throws IOException { + delegate.forceDirectory(directory); + } + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java index f335a75..a13d786 100644 --- a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournalTest.java @@ -12,8 +12,10 @@ import dev.caskeleton.application.filepublication.FilePublishOperationId; import dev.caskeleton.application.filepublication.FilePublishRequest; import dev.caskeleton.application.filepublication.LogicalFileName; import dev.caskeleton.application.filepublication.SourceRevision; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -62,6 +64,38 @@ class LocalPublicationJournalTest { .hasMessageContaining("corrupt"); } + @Test + void strictCompatibilityDecodeRequiresUtf8AndByteForByteCanonicalEncoding() { + LocalPublicationJournalRecord published = + LocalPublicationJournalRecord.sealed( + "operation-1", + "1".repeat(64), + "report--token.csv", + ".token.part", + 42, + 3, + 2, + "a".repeat(64), + 1) + .published(Instant.parse("2026-07-28T01:02:03Z"), "UNIQUE_ATOMIC_CREATE"); + byte[] canonical = LocalPublicationJournalCodec.encode(published); + + assertThat(LocalPublicationJournalCodec.decodeCanonical(canonical)).isEqualTo(published); + assertThatThrownBy( + () -> + LocalPublicationJournalCodec.decodeCanonical( + new String(canonical, StandardCharsets.UTF_8) + .replace("{", "{ ") + .getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(LocalPublicationJournalException.class) + .hasMessageContaining("corrupt"); + assertThatThrownBy( + () -> + LocalPublicationJournalCodec.decodeCanonical(new byte[] {(byte) 0xc3, (byte) 0x28})) + .isInstanceOf(LocalPublicationJournalException.class) + .hasMessageContaining("corrupt"); + } + @Test void rejectsSymlinkedControlDirectoryWithoutWritingOutsideTheBase() throws Exception { Path base = Files.createDirectory(tempDir.resolve("base")); diff --git a/src/adapter/outbound/httpclient/CLAUDE.md b/src/adapter/outbound/httpclient/CLAUDE.md index 97fbc66..dd478d5 100644 --- a/src/adapter/outbound/httpclient/CLAUDE.md +++ b/src/adapter/outbound/httpclient/CLAUDE.md @@ -32,6 +32,12 @@ Package root: `dev.caskeleton.adapter.outbound.httpclient`. - The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic logical-call deadline is R1 evidence only. +- Canonical activation is owned by `app-bootstrap`: default `DISABLED` must resolve to + `DISABLED_VERIFIED` with zero HTTP runtime resources. Provider definitions are inert unless an + exact binding selects them; the current `NOT_IMPLEMENTED` card rejects every ACTIVE selection + before provider construction. +- Legacy `app.outbound.http.*` values are explicit migration input only. They must not be globally + configuration-properties scanned or present beside canonical composition in any expected state. - Streaming must validate status before body delivery and remains bounded by a selected readiness card before production use. diff --git a/src/adapter/outbound/httpclient/README.md b/src/adapter/outbound/httpclient/README.md index 560a1c2..cfde7d5 100644 --- a/src/adapter/outbound/httpclient/README.md +++ b/src/adapter/outbound/httpclient/README.md @@ -13,8 +13,15 @@ 현재 구현은 typed operation/target foundation과 migration용 JDK client를 제공하지만 R2가 아니다. caller/configured `CallBudget` 교집합을 실제 logical call과 retry backoff에 적용하고 timeout 시 virtual-thread task를 interrupt하는 active logical deadline은 구현됐다. Apache HC5 pool, pool -acquisition bound, wire hard-cancellation evidence, canonical zero-binding composition, DNS/SSRF, -TLS/auth/proxy, decoded-body bound와 real-network qualification은 아직 없다. +acquisition bound, wire hard-cancellation evidence, DNS/SSRF, TLS/auth/proxy, decoded-body bound와 +real-network qualification은 아직 없다. + +Canonical expected-state/binding/provider map과 `DISABLED_VERIFIED` zero-binding composition은 +구현됐다. 기본 상태에서는 client, `RestClient`, executor, shutdown guard, retry/circuit-breaker +registry와 background resource가 생성되지 않는다. 현재 유일한 +`httpclient-static-buffered` readiness card가 `NOT_IMPLEMENTED`이므로 어떤 ACTIVE binding도 +provider resource 생성 전에 실패한다. 따라서 이것은 안전한 비활성화/활성화 기반이지 R2 +provider가 아니다. `application-core`에는 HTTP 타입이 없는 monotonic `CallBudget`만 추가되며 JDK facade의 `get`/`exchange`/`stream` overload가 이를 소비한다. 실제 product의 @@ -32,6 +39,28 @@ catalog이며 runtime registration API가 없다. route만 결합한다. user-info/query/fragment가 있는 base URI, absolute/scheme-relative request target, dot traversal, slash를 포함한 path variable, 사전 percent-encoding은 거부한다. +## Canonical activation과 zero-binding + +`ca-skeleton.capabilities.http-client`가 expected state와 destination binding을, +`ca-skeleton.providers.http-client`가 provider/destination/operation-catalog 정의를 소유한다. +unknown field와 malformed ID는 strict binder가 거부한다. Resolver는 exact provider, +destination, code-owned operation catalog와 operation destination 일치를 확인한 뒤 selected +readiness card를 파생한다. + +- `DISABLED`는 binding/provider definition이 모두 0개여야 하며 + `DISABLED_VERIFIED` descriptor만 만든다. +- `ACTIVE`는 binding이 하나 이상이어야 한다. +- provider definition만으로 client를 선택하거나 만들지 않는다. +- canonical composition은 expected state와 무관하게 legacy `app.outbound.http.*` 입력을 + 거부한다. 기본 `application.yml`, `.env`, env-key registry는 legacy key를 선언하지 않는다. +- 기존 `OutboundHttpSettings`는 global configuration-properties scan에서 제거됐다. migration + consumer는 `OutboundHttpSettings.bindLegacy(Binder)` 또는 직접 생성자를 명시적으로 사용한다. +- `OutboundHttpClientConfig`와 `OutboundHttpResilienceConfig`도 component scan 대상이 아니며 + legacy fork가 명시적으로 import할 때만 bean factory로 동작한다. + +현재 ACTIVE는 항상 `httpclient-static-buffered=NOT_IMPLEMENTED`에서 닫힌다. HC5 provider +factory나 transport resource를 이번 단계에서 만들지 않는다. + ## OutboundHttpClient 단일 명명 의존성(named upstream dependency)용 migration HTTP 클라이언트다. 새 application diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index bd3d5da..d110b79 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -11,8 +11,6 @@ dependencies { implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0' implementation 'org.slf4j:slf4j-api' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' } tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/httpclient/gradle.lockfile b/src/adapter/outbound/httpclient/gradle.lockfile index 368c2b3..c19a801 100644 --- a/src/adapter/outbound/httpclient/gradle.lockfile +++ b/src/adapter/outbound/httpclient/gradle.lockfile @@ -127,7 +127,6 @@ org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java index cabdaf2..72fbdeb 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java @@ -3,17 +3,16 @@ package dev.caskeleton.adapter.outbound.httpclient; import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; /** - * Registers the common outbound HTTP infrastructure beans. No default {@link OutboundHttpClient} - * bean — forking projects call {@link OutboundHttpClient#baseline} per dependency (module README). - * {@code @ConditionalOnMissingBean} on each bean lets a fork substitute its own implementation. + * Explicit-import compatibility configuration for the legacy JDK facade. + * + *

This class intentionally has no component-scanned configuration stereotype. A legacy fork may + * still import it explicitly after supplying {@link OutboundHttpSettings}; the canonical bootstrap + * composition never imports it and therefore creates none of these infrastructure beans for zero + * bindings. */ -@Configuration -@EnableConfigurationProperties(OutboundHttpSettings.class) public class OutboundHttpClientConfig { @Bean diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java index 5ca57d6..9ca8b81 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java @@ -1,14 +1,14 @@ package dev.caskeleton.adapter.outbound.httpclient; import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.bind.ConstructorBinding; +import java.util.Objects; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.util.unit.DataSize; /** - * Typed settings for the outbound HTTP client baseline, bound from {@code app.outbound.http.*}. The - * compact constructor rejects missing/zero/negative timeouts at binding time (startup failure); the - * rationale is in the module README. + * Typed settings for the explicitly constructed legacy outbound HTTP client baseline. This runtime + * value is deliberately not globally configuration-properties scanned; canonical activation is + * owned by the bootstrap composition root. * * @param connectTimeout TCP connect timeout; must be positive * @param readTimeout socket read timeout; must be positive @@ -21,7 +21,6 @@ import org.springframework.util.unit.DataSize; * @param retry retry tuning; null applies defaults * @param circuitBreaker circuit-breaker tuning; null applies defaults */ -@ConfigurationProperties(prefix = "app.outbound.http") public record OutboundHttpSettings( Duration connectTimeout, Duration readTimeout, @@ -48,7 +47,42 @@ public record OutboundHttpSettings( private static final Duration DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE = Duration.ofSeconds(60); private static final int DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN = 10; - @ConstructorBinding + /** + * Explicit migration binder for forks that still construct the legacy JDK facade. + * + *

The settings type is deliberately absent from global configuration-properties scanning. + */ + public static OutboundHttpSettings bindLegacy(Binder binder) { + Objects.requireNonNull(binder, "binder must be non-null"); + String prefix = "app.outbound.http."; + return new OutboundHttpSettings( + binder.bind(prefix + "connect-timeout", Duration.class).orElse(null), + binder.bind(prefix + "read-timeout", Duration.class).orElse(null), + binder.bind(prefix + "global-call-timeout", Duration.class).orElse(null), + binder.bind(prefix + "maximum-in-flight-calls", Integer.class).orElse(null), + binder.bind(prefix + "retry-enabled", Boolean.class).orElse(false), + binder.bind(prefix + "circuit-breaker-enabled", Boolean.class).orElse(false), + binder.bind(prefix + "response-size-limit", DataSize.class).orElse(null), + new Retry( + binder.bind(prefix + "retry.max-attempts", Integer.class).orElse(null), + binder.bind(prefix + "retry.initial-backoff", Duration.class).orElse(null), + binder.bind(prefix + "retry.backoff-multiplier", Double.class).orElse(null)), + new CircuitBreaker( + binder + .bind(prefix + "circuit-breaker.failure-rate-threshold", Float.class) + .orElse(null), + binder.bind(prefix + "circuit-breaker.sliding-window-size", Integer.class).orElse(null), + binder + .bind(prefix + "circuit-breaker.minimum-number-of-calls", Integer.class) + .orElse(null), + binder + .bind(prefix + "circuit-breaker.wait-duration-in-open-state", Duration.class) + .orElse(null), + binder + .bind(prefix + "circuit-breaker.permitted-calls-in-half-open", Integer.class) + .orElse(null))); + } + public OutboundHttpSettings { if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative()) { throw new IllegalArgumentException( diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java new file mode 100644 index 0000000..40bbaf2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolver.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationCatalog; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Pure fail-closed activation resolver. It performs no I/O and cannot construct provider resources. + */ +public final class HttpClientActivationResolver { + + public ResolvedHttpClientCapability resolve( + HttpClientCanonicalConfiguration configuration, + HttpOperationCatalogRegistry catalogRegistry, + HttpClientReadinessCardRegistry readinessRegistry) { + Objects.requireNonNull(configuration, "configuration must be non-null"); + Objects.requireNonNull(catalogRegistry, "catalogRegistry must be non-null"); + Objects.requireNonNull(readinessRegistry, "readinessRegistry must be non-null"); + + if (configuration.expectedState() == HttpClientExpectedState.DISABLED) { + if (!configuration.bindings().isEmpty()) { + throw new IllegalStateException( + "HTTP client expected-state DISABLED requires zero bindings"); + } + if (!configuration.providers().isEmpty()) { + throw new IllegalStateException( + "HTTP client expected-state DISABLED requires zero provider definitions/resources"); + } + return ResolvedHttpClientCapability.disabledVerified(); + } + + if (configuration.bindings().isEmpty()) { + throw new IllegalStateException( + "HTTP client expected-state ACTIVE requires at least one binding"); + } + + Set selectedCards = new LinkedHashSet<>(); + for (Map.Entry binding : + configuration.bindings().entrySet()) { + HttpDestinationId destinationId = binding.getKey(); + HttpClientCanonicalConfiguration.ProviderId providerId = binding.getValue(); + HttpClientCanonicalConfiguration.ProviderDefinition provider = + configuration.providers().get(providerId); + if (provider == null) { + throw new IllegalStateException( + "HTTP binding references unknown provider: " + providerId.value()); + } + HttpClientCanonicalConfiguration.DestinationDefinition destination = + provider.destinations().get(destinationId); + if (destination == null) { + throw new IllegalStateException( + "HTTP provider " + providerId.value() + " has no destination " + destinationId.value()); + } + HttpOperationCatalog catalog = catalogRegistry.require(destination.operationCatalogId()); + if (catalog.descriptors().isEmpty()) { + throw new IllegalStateException( + "HTTP operation catalog must contain at least one operation: " + + destination.operationCatalogId().value()); + } + if (!catalog.allOperationsTarget(destinationId)) { + throw new IllegalStateException( + "every operation in a bound HTTP catalog must use the same destination"); + } + if (destination.profile() + == HttpClientCanonicalConfiguration.DestinationProfile.BUFFERED_CLASSIC) { + selectedCards.add(HttpClientReadinessCardRegistry.STATIC_BUFFERED_CARD); + } + } + + for (String card : selectedCards) { + HttpClientReadinessCardRegistry.Maturity maturity = readinessRegistry.require(card); + if (maturity != HttpClientReadinessCardRegistry.Maturity.RELEASE_ELIGIBLE) { + throw new IllegalStateException( + "HTTP readiness card " + + card + + " is " + + maturity + + " and cannot be selected by ACTIVE"); + } + } + throw new IllegalStateException( + "ACTIVE HTTP composition is unavailable until an exact compatibility profile is implemented"); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java new file mode 100644 index 0000000..00b23c3 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfiguration.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Immutable canonical HTTP selection compiled before any provider resource may be constructed. + * + *

Provider definitions are inert data. Only {@link #bindings()} select a provider. + */ +public record HttpClientCanonicalConfiguration( + HttpClientExpectedState expectedState, + Map bindings, + Map providers) { + + public HttpClientCanonicalConfiguration { + Objects.requireNonNull(expectedState, "expectedState must be non-null"); + bindings = immutableSorted(bindings, destination -> destination.value()); + providers = immutableSorted(providers, ProviderId::value); + } + + private static Map immutableSorted( + Map source, java.util.function.Function keyExtractor) { + Objects.requireNonNull(source, "configuration map must be non-null"); + Map> sorted = new TreeMap<>(); + for (Map.Entry entry : source.entrySet()) { + K key = Objects.requireNonNull(entry.getKey(), "configuration key must be non-null"); + V value = Objects.requireNonNull(entry.getValue(), "configuration value must be non-null"); + String normalized = keyExtractor.apply(key); + if (sorted.putIfAbsent(normalized, Map.entry(key, value)) != null) { + throw new IllegalArgumentException( + "duplicate normalized HTTP configuration id: " + normalized); + } + } + Map copy = new LinkedHashMap<>(); + sorted.values().forEach(entry -> copy.put(entry.getKey(), entry.getValue())); + return Collections.unmodifiableMap(copy); + } + + /** Stable provider registry identifier. It is not a classpath/provider auto-selection hint. */ + public record ProviderId(String value) { + public ProviderId { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException("HTTP provider id must match [a-z][a-z0-9-]{0,62}"); + } + } + } + + /** Stable operation-catalog registry identifier. */ + public record OperationCatalogId(String value) { + public OperationCatalogId { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,127}")) { + throw new IllegalArgumentException( + "HTTP operation catalog id must match [a-z][a-z0-9-]{0,127}"); + } + } + } + + /** Inert provider definition indexed by its exact fixed destinations. */ + public record ProviderDefinition(Map destinations) { + public ProviderDefinition { + destinations = immutableSorted(destinations, HttpDestinationId::value); + } + } + + /** Minimal Phase-1 destination definition; it does not construct or qualify a transport. */ + public record DestinationDefinition( + OperationCatalogId operationCatalogId, DestinationProfile profile) { + public DestinationDefinition { + Objects.requireNonNull(operationCatalogId, "operationCatalogId must be non-null"); + Objects.requireNonNull(profile, "profile must be non-null"); + } + } + + /** Only the bounded classic profile can be described in this increment. */ + public enum DestinationProfile { + BUFFERED_CLASSIC + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java new file mode 100644 index 0000000..0e783d2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinder.java @@ -0,0 +1,162 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler; + +/** Strictly binds the two canonical HTTP configuration maps and compiles typed immutable IDs. */ +public final class HttpClientCanonicalConfigurationBinder { + + private static final String SELECTION_PREFIX = "ca-skeleton.capabilities.http-client"; + private static final String PROVIDERS_PREFIX = "ca-skeleton.providers.http-client"; + private static final List LEGACY_PATHS = + List.of( + "connect-timeout", + "read-timeout", + "global-call-timeout", + "maximum-in-flight-calls", + "retry-enabled", + "retry.max-attempts", + "retry.initial-backoff", + "retry.backoff-multiplier", + "circuit-breaker-enabled", + "circuit-breaker.failure-rate-threshold", + "circuit-breaker.sliding-window-size", + "circuit-breaker.minimum-number-of-calls", + "circuit-breaker.wait-duration-in-open-state", + "circuit-breaker.permitted-calls-in-half-open", + "response-size-limit"); + + private final Binder binder; + + public HttpClientCanonicalConfigurationBinder(Binder binder) { + this.binder = Objects.requireNonNull(binder, "binder must be non-null"); + } + + public HttpClientCanonicalConfiguration bind() { + BindHandler strict = new NoUnboundElementsBindHandler(BindHandler.DEFAULT); + RawSelection rawSelection = + binder + .bind(SELECTION_PREFIX, Bindable.of(RawSelection.class), strict) + .orElseGet(() -> new RawSelection(null, null)); + Map rawProviders = + binder + .bind( + PROVIDERS_PREFIX, Bindable.mapOf(String.class, RawProviderDefinition.class), strict) + .orElseGet(Map::of); + + HttpClientExpectedState expectedState = parseExpectedState(rawSelection.expectedState()); + Map bindings = + compileBindings(rawSelection.bindings()); + Map< + HttpClientCanonicalConfiguration.ProviderId, + HttpClientCanonicalConfiguration.ProviderDefinition> + providers = compileProviders(rawProviders); + if (hasLegacyInput()) { + throw new IllegalStateException( + "canonical HTTP client composition rejects legacy app.outbound.http input; " + + "a migration fork must bind legacy settings outside the canonical composition"); + } + return new HttpClientCanonicalConfiguration(expectedState, bindings, providers); + } + + private HttpClientExpectedState parseExpectedState(String value) { + if (value == null || value.isBlank()) { + return HttpClientExpectedState.DISABLED; + } + try { + return HttpClientExpectedState.valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + SELECTION_PREFIX + ".expected-state must be DISABLED or ACTIVE", exception); + } + } + + private static Map + compileBindings(Map rawBindings) { + if (rawBindings == null) { + return Map.of(); + } + Map compiled = + new LinkedHashMap<>(); + rawBindings.forEach( + (destination, provider) -> + compiled.put( + new HttpDestinationId(destination), + new HttpClientCanonicalConfiguration.ProviderId(provider))); + return compiled; + } + + private static Map< + HttpClientCanonicalConfiguration.ProviderId, + HttpClientCanonicalConfiguration.ProviderDefinition> + compileProviders(Map rawProviders) { + Map< + HttpClientCanonicalConfiguration.ProviderId, + HttpClientCanonicalConfiguration.ProviderDefinition> + compiled = new LinkedHashMap<>(); + rawProviders.forEach( + (providerName, rawProvider) -> { + HttpClientCanonicalConfiguration.ProviderId providerId = + new HttpClientCanonicalConfiguration.ProviderId(providerName); + Map + destinations = new LinkedHashMap<>(); + Map rawDestinations = + rawProvider == null || rawProvider.destinations() == null + ? Map.of() + : rawProvider.destinations(); + rawDestinations.forEach( + (destinationName, rawDestination) -> { + if (rawDestination == null || rawDestination.operationCatalog() == null) { + throw new IllegalArgumentException( + "HTTP provider destination operation-catalog must be configured"); + } + HttpClientCanonicalConfiguration.DestinationProfile profile = + parseProfile(rawDestination.profile()); + destinations.put( + new HttpDestinationId(destinationName), + new HttpClientCanonicalConfiguration.DestinationDefinition( + new HttpClientCanonicalConfiguration.OperationCatalogId( + rawDestination.operationCatalog()), + profile)); + }); + compiled.put( + providerId, new HttpClientCanonicalConfiguration.ProviderDefinition(destinations)); + }); + return compiled; + } + + private static HttpClientCanonicalConfiguration.DestinationProfile parseProfile(String value) { + if (value == null || value.isBlank()) { + return HttpClientCanonicalConfiguration.DestinationProfile.BUFFERED_CLASSIC; + } + try { + return HttpClientCanonicalConfiguration.DestinationProfile.valueOf( + value.trim().toUpperCase(Locale.ROOT).replace('-', '_')); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "HTTP provider destination profile must be BUFFERED_CLASSIC", exception); + } + } + + private boolean hasLegacyInput() { + return LEGACY_PATHS.stream() + .anyMatch(path -> binder.bind("app.outbound.http." + path, String.class).isBound()); + } + + /** Binding-only shape. Runtime code receives only the compiled immutable configuration. */ + public record RawSelection(String expectedState, Map bindings) {} + + /** Binding-only provider shape. */ + public record RawProviderDefinition(Map destinations) {} + + /** Binding-only destination shape for the current bounded classic profile. */ + public record RawDestinationDefinition(String operationCatalog, String profile) {} +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java new file mode 100644 index 0000000..f81828f --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientExpectedState.java @@ -0,0 +1,7 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +/** Deployment assertion for the canonical HTTP client capability selection. */ +public enum HttpClientExpectedState { + DISABLED, + ACTIVE +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java new file mode 100644 index 0000000..9f50505 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientReadinessCardRegistry.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import java.util.Map; +import java.util.Objects; + +/** Immutable readiness-card maturity registry for deterministic activation decisions. */ +public final class HttpClientReadinessCardRegistry { + + public static final String STATIC_BUFFERED_CARD = "httpclient-static-buffered"; + + private final Map maturities; + + public HttpClientReadinessCardRegistry(Map maturities) { + Objects.requireNonNull(maturities, "maturities must be non-null"); + this.maturities = Map.copyOf(maturities); + } + + /** Current repository truth: the bounded static provider card has not been implemented. */ + public static HttpClientReadinessCardRegistry current() { + return new HttpClientReadinessCardRegistry( + Map.of(STATIC_BUFFERED_CARD, Maturity.NOT_IMPLEMENTED)); + } + + public Maturity require(String cardId) { + Maturity maturity = maturities.get(cardId); + if (maturity == null) { + throw new IllegalStateException("unregistered HTTP readiness card: " + cardId); + } + return maturity; + } + + public enum Maturity { + NOT_IMPLEMENTED, + IMPLEMENTED_CANDIDATE, + RELEASE_ELIGIBLE + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java new file mode 100644 index 0000000..5100e94 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpOperationCatalogRegistry.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationCatalog; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Immutable code-owned operation-catalog registry. Runtime registration is intentionally absent. + */ +public final class HttpOperationCatalogRegistry { + + private final Map + catalogs; + + public HttpOperationCatalogRegistry( + Map catalogs) { + Objects.requireNonNull(catalogs, "catalogs must be non-null"); + Map< + String, + Map.Entry> + sorted = new TreeMap<>(); + catalogs.forEach( + (id, catalog) -> { + Objects.requireNonNull(id, "catalog id must be non-null"); + Objects.requireNonNull(catalog, "operation catalog must be non-null"); + if (sorted.putIfAbsent(id.value(), Map.entry(id, catalog)) != null) { + throw new IllegalArgumentException( + "duplicate HTTP operation catalog id: " + id.value()); + } + }); + Map copy = + new LinkedHashMap<>(); + sorted.values().forEach(entry -> copy.put(entry.getKey(), entry.getValue())); + this.catalogs = Collections.unmodifiableMap(copy); + } + + public static HttpOperationCatalogRegistry empty() { + return new HttpOperationCatalogRegistry(Map.of()); + } + + public HttpOperationCatalog require( + HttpClientCanonicalConfiguration.OperationCatalogId catalogId) { + HttpOperationCatalog catalog = catalogs.get(catalogId); + if (catalog == null) { + throw new IllegalStateException("unregistered HTTP operation catalog: " + catalogId.value()); + } + return catalog; + } + + public int size() { + return catalogs.size(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java new file mode 100644 index 0000000..2e0f129 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/activation/ResolvedHttpClientCapability.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import java.util.Set; + +/** Sanitized inert activation descriptor. It never owns a client, executor, pool, or probe. */ +public record ResolvedHttpClientCapability( + State state, int selectedBindingCount, Set selectedReadinessCards) { + + public ResolvedHttpClientCapability { + selectedReadinessCards = Set.copyOf(selectedReadinessCards); + if (selectedBindingCount < 0) { + throw new IllegalArgumentException("selectedBindingCount must be non-negative"); + } + } + + public static ResolvedHttpClientCapability disabledVerified() { + return new ResolvedHttpClientCapability(State.DISABLED_VERIFIED, 0, Set.of()); + } + + public enum State { + DISABLED_VERIFIED + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java index 48ae59c..231a25a 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/operation/HttpOperationCatalog.java @@ -34,4 +34,11 @@ public final class HttpOperationCatalog { public Collection descriptors() { return descriptors.values(); } + + /** Startup control-plane validation without exposing descriptor internals across packages. */ + public boolean allOperationsTarget(HttpDestinationId destinationId) { + Objects.requireNonNull(destinationId, "destinationId must be non-null"); + return descriptors.values().stream() + .allMatch(descriptor -> descriptor.destinationId().equals(destinationId)); + } } diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java index aeba57c..2ba6683 100644 --- a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java @@ -13,13 +13,14 @@ import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.core.instrument.config.MeterFilterReply; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.function.Function; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; /** - * Builds the {@link OutboundHttpResilience} bean and normalises the resilience4j metrics. + * Explicit-import compatibility configuration that builds the legacy {@link OutboundHttpResilience} + * bean and normalises the resilience4j metrics. * *

Micrometer 1.15.x incompatibility: {@code MeterFilter.replaceTagValues}/{@code renameTag} do * not transform the tags of the {@code FunctionCounter}/{@code DefaultGauge} instances registered @@ -27,7 +28,6 @@ import org.springframework.context.annotation.Configuration; * map(Meter.Id)} implementation is required (verified on Micrometer 1.15.11 / Resilience4j 2.2.0). * Full normalisation / DENY-policy rationale is in the module README. */ -@Configuration public class OutboundHttpResilienceConfig { private static final String RETRY_CALLS_METER = "resilience4j.retry.calls"; @@ -162,7 +162,7 @@ public class OutboundHttpResilienceConfig { List newTags = new ArrayList<>(); for (Tag t : id.getTags()) { if ("state".equals(t.getKey())) { - newTags.add(Tag.of("state", t.getValue().toUpperCase())); + newTags.add(Tag.of("state", t.getValue().toUpperCase(Locale.ROOT))); } else { newTags.add(t); } diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java index 7486b9e..cbac61e 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java @@ -5,10 +5,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; import org.springframework.util.unit.DataSize; /** @@ -205,45 +203,39 @@ class OutboundHttpSettingsTest { .hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT"); } - // --- ApplicationContextRunner binding --- - - @Configuration - @EnableConfigurationProperties(OutboundHttpSettings.class) - @EnableAutoConfiguration - static class BindingConfig {} + // --- Explicit migration binding --- @Test - void settingsBindFromApplicationContextRunner() { - new ApplicationContextRunner() - .withUserConfiguration(BindingConfig.class) - .withPropertyValues( - "app.outbound.http.connect-timeout=2s", - "app.outbound.http.read-timeout=5s", - "app.outbound.http.global-call-timeout=10s", - "app.outbound.http.maximum-in-flight-calls=7", - "app.outbound.http.retry-enabled=true", - "app.outbound.http.circuit-breaker-enabled=false", - "app.outbound.http.response-size-limit=10MB") - .run( - ctx -> { - assertThat(ctx).hasNotFailed(); - OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class); - assertThat(s.connectTimeout()).isEqualTo(Duration.ofSeconds(2)); - assertThat(s.readTimeout()).isEqualTo(Duration.ofSeconds(5)); - assertThat(s.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10)); - assertThat(s.maximumInFlightCalls()).isEqualTo(7); - assertThat(s.retryEnabled()).isTrue(); - assertThat(s.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); - }); + void settingsBindThroughTheExplicitLegacyMigrationApi() { + OutboundHttpSettings s = + bindLegacy( + "app.outbound.http.connect-timeout", "2s", + "app.outbound.http.read-timeout", "5s", + "app.outbound.http.global-call-timeout", "10s", + "app.outbound.http.maximum-in-flight-calls", "7", + "app.outbound.http.retry-enabled", "true", + "app.outbound.http.circuit-breaker-enabled", "false", + "app.outbound.http.response-size-limit", "10MB"); + + assertThat(s.connectTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(s.readTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(s.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(s.maximumInFlightCalls()).isEqualTo(7); + assertThat(s.retryEnabled()).isTrue(); + assertThat(s.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); } @Test - void contextFailsWhenConnectTimeoutIsMissingFromBinding() { - new ApplicationContextRunner() - .withUserConfiguration(BindingConfig.class) - .withPropertyValues( - "app.outbound.http.read-timeout=5s", "app.outbound.http.global-call-timeout=10s") - .run(ctx -> assertThat(ctx).hasFailed()); + void explicitLegacyBindingFailsWhenConnectTimeoutIsMissing() { + assertThatThrownBy( + () -> + bindLegacy( + "app.outbound.http.read-timeout", + "5s", + "app.outbound.http.global-call-timeout", + "10s")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT"); } // --- nested record 기본값 (직접 생성) --- @@ -369,37 +361,42 @@ class OutboundHttpSettingsTest { .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN"); } - // --- ApplicationContextRunner 바인딩 --- + // --- explicit legacy nested binding --- @Test - void nestedSettingsBindFromApplicationContextRunner() { - new ApplicationContextRunner() - .withUserConfiguration(BindingConfig.class) - .withPropertyValues( - "app.outbound.http.connect-timeout=2s", - "app.outbound.http.read-timeout=5s", - "app.outbound.http.global-call-timeout=10s", - "app.outbound.http.retry.max-attempts=5", - "app.outbound.http.retry.initial-backoff=250ms", - "app.outbound.http.retry.backoff-multiplier=3.0", - "app.outbound.http.circuit-breaker.failure-rate-threshold=25", - "app.outbound.http.circuit-breaker.sliding-window-size=20", - "app.outbound.http.circuit-breaker.minimum-number-of-calls=7", - "app.outbound.http.circuit-breaker.wait-duration-in-open-state=30s", - "app.outbound.http.circuit-breaker.permitted-calls-in-half-open=4") - .run( - ctx -> { - assertThat(ctx).hasNotFailed(); - OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class); - assertThat(s.retry().maxAttempts()).isEqualTo(5); - assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(250)); - assertThat(s.retry().backoffMultiplier()).isEqualTo(3.0); - assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(25f); - assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(20); - assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(7); - assertThat(s.circuitBreaker().waitDurationInOpenState()) - .isEqualTo(Duration.ofSeconds(30)); - assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(4); - }); + void nestedSettingsBindThroughTheExplicitLegacyMigrationApi() { + OutboundHttpSettings s = + bindLegacy( + "app.outbound.http.connect-timeout", "2s", + "app.outbound.http.read-timeout", "5s", + "app.outbound.http.global-call-timeout", "10s", + "app.outbound.http.retry.max-attempts", "5", + "app.outbound.http.retry.initial-backoff", "250ms", + "app.outbound.http.retry.backoff-multiplier", "3.0", + "app.outbound.http.circuit-breaker.failure-rate-threshold", "25", + "app.outbound.http.circuit-breaker.sliding-window-size", "20", + "app.outbound.http.circuit-breaker.minimum-number-of-calls", "7", + "app.outbound.http.circuit-breaker.wait-duration-in-open-state", "30s", + "app.outbound.http.circuit-breaker.permitted-calls-in-half-open", "4"); + + assertThat(s.retry().maxAttempts()).isEqualTo(5); + assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(250)); + assertThat(s.retry().backoffMultiplier()).isEqualTo(3.0); + assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(25f); + assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(20); + assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(7); + assertThat(s.circuitBreaker().waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(30)); + assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(4); + } + + private static OutboundHttpSettings bindLegacy(String... keyValues) { + if (keyValues.length % 2 != 0) { + throw new IllegalArgumentException("keyValues must contain key/value pairs"); + } + MapConfigurationPropertySource source = new MapConfigurationPropertySource(); + for (int index = 0; index < keyValues.length; index += 2) { + source.put(keyValues[index], keyValues[index + 1]); + } + return OutboundHttpSettings.bindLegacy(new Binder(source)); } } diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java new file mode 100644 index 0000000..e0adf6c --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientActivationResolverTest.java @@ -0,0 +1,181 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationCatalog; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationDescriptor; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationId; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class HttpClientActivationResolverTest { + + private static final HttpDestinationId DESTINATION = new HttpDestinationId("partner"); + private static final HttpClientCanonicalConfiguration.ProviderId PROVIDER = + new HttpClientCanonicalConfiguration.ProviderId("jdk-r1"); + private static final HttpClientCanonicalConfiguration.OperationCatalogId CATALOG = + new HttpClientCanonicalConfiguration.OperationCatalogId("partner-v1"); + + private final HttpClientActivationResolver resolver = new HttpClientActivationResolver(); + private final HttpClientReadinessCardRegistry readiness = + HttpClientReadinessCardRegistry.current(); + + @Test + void disabledWithNoBindingsResolvesToDisabledVerifiedAndSelectsNothing() { + ResolvedHttpClientCapability resolved = + resolver.resolve( + configuration(HttpClientExpectedState.DISABLED, Map.of(), Map.of()), + HttpOperationCatalogRegistry.empty(), + readiness); + + assertThat(resolved.state()).isEqualTo(ResolvedHttpClientCapability.State.DISABLED_VERIFIED); + assertThat(resolved.selectedBindingCount()).isZero(); + assertThat(resolved.selectedReadinessCards()).isEmpty(); + } + + @Test + void disabledRejectsBindingsAndActiveRejectsZeroBindings() { + assertThatThrownBy( + () -> + resolver.resolve( + configuration( + HttpClientExpectedState.DISABLED, Map.of(DESTINATION, PROVIDER), Map.of()), + HttpOperationCatalogRegistry.empty(), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("DISABLED") + .hasMessageContaining("bindings"); + + HttpClientCanonicalConfiguration.ProviderDefinition inertProviderDefinition = + new HttpClientCanonicalConfiguration.ProviderDefinition(Map.of()); + assertThatThrownBy( + () -> + resolver.resolve( + configuration( + HttpClientExpectedState.DISABLED, + Map.of(), + Map.of(PROVIDER, inertProviderDefinition)), + HttpOperationCatalogRegistry.empty(), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("DISABLED") + .hasMessageContaining("provider"); + + assertThatThrownBy( + () -> + resolver.resolve( + configuration(HttpClientExpectedState.ACTIVE, Map.of(), Map.of()), + HttpOperationCatalogRegistry.empty(), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ACTIVE") + .hasMessageContaining("binding"); + } + + @Test + void activeRequiresExactProviderDestinationAndCatalog() { + assertThatThrownBy( + () -> + resolver.resolve( + configuration( + HttpClientExpectedState.ACTIVE, Map.of(DESTINATION, PROVIDER), Map.of()), + HttpOperationCatalogRegistry.empty(), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("provider") + .hasMessageContaining("jdk-r1"); + + HttpClientCanonicalConfiguration.ProviderDefinition providerWithNoDestination = + new HttpClientCanonicalConfiguration.ProviderDefinition(Map.of()); + assertThatThrownBy( + () -> + resolver.resolve( + configuration( + HttpClientExpectedState.ACTIVE, + Map.of(DESTINATION, PROVIDER), + Map.of(PROVIDER, providerWithNoDestination)), + HttpOperationCatalogRegistry.empty(), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("destination") + .hasMessageContaining("partner"); + + assertThatThrownBy( + () -> + resolver.resolve( + activeConfiguration(), HttpOperationCatalogRegistry.empty(), readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("operation catalog") + .hasMessageContaining("partner-v1"); + } + + @Test + void catalogOperationsMustBelongToTheBoundDestination() { + HttpOperationCatalog wrongCatalog = + new HttpOperationCatalog(List.of(descriptor(new HttpDestinationId("other")))); + + assertThatThrownBy( + () -> + resolver.resolve( + activeConfiguration(), + new HttpOperationCatalogRegistry(Map.of(CATALOG, wrongCatalog)), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("same destination"); + } + + @Test + void activeBufferedProfileDerivesStaticCardThenFailsItsCurrentMaturity() { + HttpOperationCatalog catalog = new HttpOperationCatalog(List.of(descriptor(DESTINATION))); + + assertThatThrownBy( + () -> + resolver.resolve( + activeConfiguration(), + new HttpOperationCatalogRegistry(Map.of(CATALOG, catalog)), + readiness)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(HttpClientReadinessCardRegistry.STATIC_BUFFERED_CARD) + .hasMessageContaining("NOT_IMPLEMENTED"); + } + + private static HttpClientCanonicalConfiguration activeConfiguration() { + HttpClientCanonicalConfiguration.DestinationDefinition destination = + new HttpClientCanonicalConfiguration.DestinationDefinition( + CATALOG, HttpClientCanonicalConfiguration.DestinationProfile.BUFFERED_CLASSIC); + HttpClientCanonicalConfiguration.ProviderDefinition provider = + new HttpClientCanonicalConfiguration.ProviderDefinition(Map.of(DESTINATION, destination)); + return configuration( + HttpClientExpectedState.ACTIVE, Map.of(DESTINATION, PROVIDER), Map.of(PROVIDER, provider)); + } + + private static HttpClientCanonicalConfiguration configuration( + HttpClientExpectedState expectedState, + Map bindings, + Map< + HttpClientCanonicalConfiguration.ProviderId, + HttpClientCanonicalConfiguration.ProviderDefinition> + providers) { + return new HttpClientCanonicalConfiguration(expectedState, bindings, providers); + } + + private static HttpOperationDescriptor descriptor(HttpDestinationId destinationId) { + return new HttpOperationDescriptor( + new HttpOperationId(destinationId.value() + ".fetch.v1"), + destinationId, + 1, + HttpOperationDescriptor.Method.GET, + "/items/{id}", + HttpOperationDescriptor.OperationSemantics.SAFE_READ, + HttpOperationDescriptor.RequestMode.NONE, + HttpOperationDescriptor.ResponseMode.BUFFERED, + Set.of(200), + 0, + 1, + 1024); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java new file mode 100644 index 0000000..a5f10b6 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/activation/HttpClientCanonicalConfigurationBinderTest.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.outbound.httpclient.activation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +class HttpClientCanonicalConfigurationBinderTest { + + @Test + void bindsTheCanonicalSelectionAndProviderMap() { + HttpClientCanonicalConfiguration configuration = + bind( + Map.of( + "ca-skeleton.capabilities.http-client.expected-state", "ACTIVE", + "ca-skeleton.capabilities.http-client.bindings.partner-catalog", + "apache-hc5-classic", + "ca-skeleton.providers.http-client.apache-hc5-classic.destinations.partner-catalog.operation-catalog", + "partner-catalog-v1", + "ca-skeleton.providers.http-client.apache-hc5-classic.destinations.partner-catalog.profile", + "BUFFERED_CLASSIC")); + + HttpClientCanonicalConfiguration.ProviderId providerId = + new HttpClientCanonicalConfiguration.ProviderId("apache-hc5-classic"); + HttpClientCanonicalConfiguration.ProviderDefinition provider = + configuration.providers().get(providerId); + + assertThat(configuration.expectedState()).isEqualTo(HttpClientExpectedState.ACTIVE); + assertThat(configuration.bindings()) + .containsEntry( + new dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId( + "partner-catalog"), + providerId); + assertThat(provider.destinations()).hasSize(1); + assertThat( + provider + .destinations() + .get( + new dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId( + "partner-catalog")) + .operationCatalogId() + .value()) + .isEqualTo("partner-catalog-v1"); + } + + @Test + void absentCanonicalConfigurationDefaultsToDisabledEmptyMaps() { + HttpClientCanonicalConfiguration configuration = bind(Map.of()); + + assertThat(configuration.expectedState()).isEqualTo(HttpClientExpectedState.DISABLED); + assertThat(configuration.bindings()).isEmpty(); + assertThat(configuration.providers()).isEmpty(); + } + + @Test + void rejectsUnknownCanonicalFields() { + assertThatThrownBy( + () -> + bind( + Map.of( + "ca-skeleton.capabilities.http-client.expected-state", "DISABLED", + "ca-skeleton.capabilities.http-client.enabled", "true"))) + .isInstanceOf(RuntimeException.class) + .rootCause() + .hasMessageContaining("enabled"); + } + + @Test + void rejectsUnknownProviderDestinationFields() { + assertThatThrownBy( + () -> + bind( + Map.of( + "ca-skeleton.providers.http-client.jdk-r1.destinations.partner.operation-catalog", + "partner-v1", + "ca-skeleton.providers.http-client.jdk-r1.destinations.partner.raw-url", + "https://example.test"))) + .isInstanceOf(RuntimeException.class) + .rootCause() + .hasMessageContaining("raw-url"); + } + + @Test + void rejectsMalformedIdsAndUnknownExpectedState() { + assertThatThrownBy( + () -> + bind( + Map.of( + "ca-skeleton.capabilities.http-client.bindings.BAD_destination", "jdk-r1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destination id"); + + assertThatThrownBy( + () -> + bind( + Map.of("ca-skeleton.capabilities.http-client.expected-state", "MAYBE_ENABLED"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expected-state"); + } + + @Test + void rejectsSimultaneousCanonicalAndLegacyActivationInput() { + Map properties = new LinkedHashMap<>(); + properties.put("ca-skeleton.capabilities.http-client.expected-state", "ACTIVE"); + properties.put("ca-skeleton.capabilities.http-client.bindings.partner", "jdk-r1"); + properties.put("app.outbound.http.connect-timeout", "2s"); + + assertThatThrownBy(() -> bind(properties)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("canonical") + .hasMessageContaining("legacy"); + } + + @Test + void rejectsLegacyInputEvenWhenCanonicalStateIsDisabled() { + assertThatThrownBy( + () -> + bind( + Map.of( + "ca-skeleton.capabilities.http-client.expected-state", "DISABLED", + "app.outbound.http.connect-timeout", "2s"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("canonical") + .hasMessageContaining("legacy"); + } + + private static HttpClientCanonicalConfiguration bind(Map properties) { + Binder binder = new Binder(new MapConfigurationPropertySource(properties)); + return new HttpClientCanonicalConfigurationBinder(binder).bind(); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java index fd8c650..649a07f 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java @@ -14,7 +14,6 @@ import java.util.Iterator; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -99,9 +98,13 @@ class OutboundHttpResilienceConfigTest { // --- ApplicationContextRunner test for startup failure path --- @Configuration - @EnableConfigurationProperties(OutboundHttpSettings.class) static class RetryEnabledNoMeterConfig { + @Bean + OutboundHttpSettings settings() { + return retrySettings(); + } + @Bean OutboundHttpShutdownGuard guard() { return new OutboundHttpShutdownGuard(); diff --git a/src/adapter/outbound/notification/CLAUDE.md b/src/adapter/outbound/notification/CLAUDE.md index a8d9f18..660f77e 100644 --- a/src/adapter/outbound/notification/CLAUDE.md +++ b/src/adapter/outbound/notification/CLAUDE.md @@ -12,10 +12,28 @@ Package root: `dev.caskeleton.adapter.outbound.notification`. ## Responsibility -- Implement notification provider routing and provider-specific Slack/email clients behind ports. -- Own provider settings, technical fallback, and provider adaptation. +- Preserve the current raw notification router/provider seams only as the `R0 legacy` compatibility + baseline until the reviewed canonical cutover removes them. +- Implement future provider protocols behind application-owned ports without leaking SDK, transport, + bootstrap, or persistence types. +- Own provider settings, technical fallback mechanics, and provider adaptation; application policy + owns mode, eligibility, retry/fallback decisions and business failure semantics. - Reuse `adapter:outbound:support` for shared outbound concerns. +## Current R0 freeze + +- `RoutingNotifier` performs route-list fan-out over `(Channel, providerId)`. +- `FailOpenNotificationProvider` applies one global fail-open rule. +- `google-email`/`GoogleEmailClient` and `slack-webhook`/`SlackClient` are fake-only extension seams, + not production integrations or qualified provider cards. +- Checked-in provider selector keys drift from the router's `routes` + provider `enabled` grammar. + Preserve and document that drift until the canonical graph replaces it; do not silently reinterpret + the old keys. +- There are no feature/application production consumers and no real-provider, durable, receipt, + security, load, or rotation evidence. +- The exact legacy deletion inventory lives in [README.md](README.md). Do not add behavior to those + classes while building their canonical replacements. + ## Boundaries - Allowed dependency edges come only from the module's diff --git a/src/adapter/outbound/notification/README.md b/src/adapter/outbound/notification/README.md index df41292..4fbe71c 100644 --- a/src/adapter/outbound/notification/README.md +++ b/src/adapter/outbound/notification/README.md @@ -1,13 +1,34 @@ -# adapter:outbound:notification — 설계 결정 참조 +# adapter:outbound:notification — R0 legacy truth + +> 현재 구현 전체는 교체 전 호환성 기준선인 `R0 legacy`다. `GoogleEmailClient`와 +> `SlackClient`는 project-supplied seam일 뿐 실제 Google Mail 또는 Slack 연동이 아니며, +> provider/card qualification evidence도 없다. 알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다. -허용/금지 의존 정책은 `src/build.gradle` 의 -`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직 -별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 -참조용 기록이다. +허용/금지 의존 정책은 `src/config/architecture/modules.json`의 +`adapter-outbound-notification` row가 SSOT다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 +근거**와 canonical 구현 전 삭제 대상을 모아둔 참조용 기록이다. + +## Task 1 R0 truth table + +| 축 | 현재 사실 | 증거와 한계 | +| --- | --- | --- | +| application contract | raw `NotificationPort.notify(Channel, route, Notification)` | mode, transaction, receipt, attempt certainty가 없는 R0 port | +| routing | `(channel, providerId)` registry + route별 provider ID list fan-out | `RoutingNotifierTest`; route list의 모든 provider를 순서대로 호출 | +| provider failure | 모든 provider를 중앙 `FailOpenNotificationProvider`로 감싸고 예외를 삼킴 | global fail-open이며 application kind별 정책이 아님 | +| unbound route | `AdapterDisabledException` fail-fast | `NotificationAdapterTest`; disabled sentinel은 없음 | +| email seam | `google-email` + `GoogleEmailClient` interface | module 안 production client/SDK/credential/protocol 구현 0 | +| Slack seam | `slack-webhook` + `SlackClient` interface | module 안 production client/SDK/credential/protocol 구현 0 | +| configuration | code는 `app.notification.routes.*`와 provider별 `*.enabled`를 읽음 | checked-in `application.yml`/env registry의 `app.notification.{slack,email}.provider` selector와 drift | +| production consumer | feature/application production consumer 0 | main source에는 application contract 선언, adapter 구현과 bootstrap composition만 존재 | +| evidence grade | local fake/contract baseline | real provider, durability, callback, security, load evidence 0; 모든 seam `R0 legacy` | + +selector drift는 이 기준선의 일부다. Task 1에서는 고치지 않는다. canonical graph가 준비되고 +cutover evidence가 생기기 전까지 기존 key를 새 의미로 재사용하거나 legacy class에 production +동작을 추가하지 않는다. ## 모듈 개요 @@ -32,3 +53,26 @@ client 는 포킹 프로젝트가 채우는 seam 이다. `channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예: `GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가 구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다. + +## Wave G deletion inventory + +다음 surface는 canonical-only cutover와 retained evidence 검증이 끝난 뒤 한 묶음으로 제거한다. +그 전에는 동작을 확장하지 않고 R0 회귀 기준선으로만 유지한다. + +- application R0 contract: + `Channel`, `Notification`, raw `NotificationPort` +- router/decorator SPI: + `NotificationConfig`, `NotificationRoutesSettings`, `RoutingNotifier`, + `NotificationProvider`, `FailOpenNotificationProvider` +- fake-only Google email seam: + `GoogleEmailClient`, `GoogleEmailProvider`, `GoogleEmailNotificationAdapterConfig` +- fake-only Slack webhook seam: + `SlackClient`, `SlackWebhookProvider`, `SlackNotificationAdapterConfig` +- legacy configuration/tests: + `app.notification.routes.*`, `app.notification.google-email.enabled`, + `app.notification.slack-webhook.enabled`, drifted + `APP_NOTIFICATION_EMAIL_PROVIDER`/`APP_NOTIFICATION_SLACK_PROVIDER`, + `NotificationAdapterTest`, `RoutingNotifierTest`와 bootstrap legacy gating cases + +accepted 또는 indeterminate work를 inventory하지 않은 상태에서 이 목록을 삭제하거나 canonical +provider로 자동 재전송하지 않는다. diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index 705d6da..f0996bf 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -63,11 +63,20 @@ adapters implement application/domain ports directly and must not depend on this | Mode | Propagation | Isolation | Read-only | |---|---|---|---| | `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | +| `inRootWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | | `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` | | `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` | Pre-built templates are immutable after construction so concurrent callers cannot -observe each other's reconfiguration. +observe each other's reconfiguration. `inRootWrite` reuses the pre-built write template, +but first checks `TransactionSynchronizationManager.isActualTransactionActive()`. +When an actual ambient transaction exists it MUST throw +`NestedRootTransactionRejectedException` before invoking either the action or the +`PlatformTransactionManager`. It MUST NOT use `NEVER` or `REQUIRES_NEW`. + +`inRootWrite` returns its action value only after `TransactionTemplate.execute` has +committed. A commit failure propagates the transaction exception and no success value +is returned to the caller. ### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract) diff --git a/src/adapter/outbound/persistence-jpa/README.md b/src/adapter/outbound/persistence-jpa/README.md index 84182a9..5b02f9c 100644 --- a/src/adapter/outbound/persistence-jpa/README.md +++ b/src/adapter/outbound/persistence-jpa/README.md @@ -17,6 +17,19 @@ readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 ra 사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED` 로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract). +### 왜 `inRootWrite`가 별도 템플릿이나 `NEVER` propagation을 만들지 않나 +`inRootWrite`의 실행 속성은 `inWrite`와 같은 `WRITE + REQUIRED + READ_COMMITTED`라 기존 +write template을 재사용한다. 차이는 실행 전 precondition이다. +`TransactionSynchronizationManager.isActualTransactionActive()`가 `true`이면 action과 +`PlatformTransactionManager`를 호출하기 전에 +`NestedRootTransactionRejectedException`으로 fail-fast한다. `REQUIRES_NEW`로 suspend해서 +"root처럼 보이게" 하지 않으므로 호출자 transaction과 독립 commit되는 silent 의미 변경이 없다. + +`TransactionTemplate.execute`는 commit까지 성공한 다음 값을 반환한다. 따라서 +`inRootWrite`의 결과는 post-commit에만 호출자에게 보이고, commit 실패는 값 대신 원래 transaction +예외로 전파된다. 이 보장은 action이 외부 객체를 직접 변경하는 것을 되돌리는 보상이 아니라, +경계의 반환값을 성공으로 노출하지 않는 계약이다. + ## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort` ### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값) diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java index dd2ab94..6d437e2 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java @@ -3,6 +3,7 @@ package dev.caskeleton.adapter.outbound.persistence.idempotency; import java.time.Clock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -12,6 +13,10 @@ import org.springframework.transaction.annotation.Transactional; * also enforced lazily on read and on reclaim (see README "idempotency"). */ @Component +@ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "jdbc", + matchIfMissing = true) public class IdempotencyReaper { private static final Logger log = LoggerFactory.getLogger(IdempotencyReaper.class); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java index a425899..ae7ff73 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java @@ -15,6 +15,7 @@ import java.util.UUID; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @@ -24,6 +25,10 @@ import org.springframework.stereotype.Repository; * split. See README "idempotency" for the concurrency and §F rationale. */ @Repository +@ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "jdbc", + matchIfMissing = true) public class IdempotencyStoreAdapter implements IdempotencyStorePort { /** §F threshold: payloads up to this size are stored inline in the DB row. */ diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java index cea0b80..8fa8cdf 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java @@ -1,12 +1,14 @@ package dev.caskeleton.adapter.outbound.persistence.transaction; import dev.caskeleton.application.transaction.Isolation; +import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.transaction.TransactionPort; import java.util.function.Supplier; import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; /** @@ -47,6 +49,14 @@ public class SpringTransactionPort implements TransactionPort { return writeTemplate.execute(status -> action.get()); } + @Override + public T inRootWrite(Supplier action) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new NestedRootTransactionRejectedException(); + } + return writeTemplate.execute(status -> action.get()); + } + @Override public T inRead(Supplier action) { return readTemplate.execute(status -> action.get()); diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java index dd18f74..f8626b2 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java @@ -1,18 +1,30 @@ package dev.caskeleton.adapter.outbound.persistence.transaction; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.TransactionException; import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.TransactionSystemException; import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; class SpringTransactionPortTest { + @AfterEach + void clearTransactionState() { + TransactionSynchronizationManager.clear(); + } + @Test void inWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() { RecordingTransactionManager tm = new RecordingTransactionManager(); @@ -48,6 +60,88 @@ class SpringTransactionPortTest { assertThat(definition.isReadOnly()).isTrue(); } + @Test + void inRootWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + String result = port.inRootWrite(() -> "ok"); + + assertThat(result).isEqualTo("ok"); + assertThat(tm.definitions).hasSize(1); + TransactionDefinition definition = tm.definitions.get(0); + assertThat(definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED); + assertThat(definition.getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + assertThat(definition.isReadOnly()).isFalse(); + assertThat(tm.commits).isOne(); + assertThat(tm.rollbacks).isZero(); + } + + @Test + void inRootWriteRejectsAmbientActualTransactionBeforeActionOrTransactionManagerSideEffects() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + AtomicBoolean actionCalled = new AtomicBoolean(); + TransactionSynchronizationManager.setActualTransactionActive(true); + + assertThatThrownBy( + () -> + port.inRootWrite( + () -> { + actionCalled.set(true); + return "not-visible"; + })) + .isInstanceOf(NestedRootTransactionRejectedException.class); + + assertThat(actionCalled).isFalse(); + assertThat(tm.definitions).isEmpty(); + assertThat(tm.commitAttempts).isZero(); + assertThat(tm.rollbacks).isZero(); + } + + @Test + void inRootWriteReturnsOnlyAfterPhysicalCommitCompletes() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + String result = + port.inRootWrite( + () -> { + tm.lifecycle.add("action"); + return "committed"; + }); + tm.lifecycle.add("returned"); + + assertThat(result).isEqualTo("committed"); + assertThat(tm.lifecycle).containsExactly("begin", "action", "commit", "returned"); + } + + @Test + void inRootWritePropagatesCommitFailureWithoutPublishingCallerVisibleResult() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + tm.failCommit = true; + SpringTransactionPort port = new SpringTransactionPort(tm); + AtomicReference callerVisible = new AtomicReference<>(); + + assertThatThrownBy( + () -> + callerVisible.set( + port.inRootWrite( + () -> { + tm.lifecycle.add("action"); + return "must-not-be-visible"; + }))) + .isInstanceOf(TransactionSystemException.class) + .hasMessageContaining("commit failed"); + + assertThat(callerVisible).hasValue(null); + assertThat(tm.commitAttempts).isOne(); + assertThat(tm.commits).isZero(); + assertThat(tm.lifecycle).containsExactly("begin", "action", "commit-failed"); + } + @Test void inNewUsesRequiresNewPropagationReadCommittedIsolationAndNotReadOnly() { RecordingTransactionManager tm = new RecordingTransactionManager(); @@ -116,6 +210,9 @@ class SpringTransactionPortTest { private static final class RecordingTransactionManager implements PlatformTransactionManager { private final List definitions = new ArrayList<>(); + private final List lifecycle = new ArrayList<>(); + private boolean failCommit; + private int commitAttempts; private int commits; private int rollbacks; @@ -123,11 +220,18 @@ class SpringTransactionPortTest { public TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException { definitions.add(definition); + lifecycle.add("begin"); return new SimpleTransactionStatus(true); } @Override public void commit(TransactionStatus status) throws TransactionException { + commitAttempts++; + if (failCommit) { + lifecycle.add("commit-failed"); + throw new TransactionSystemException("commit failed"); + } + lifecycle.add("commit"); commits++; } diff --git a/src/app-bootstrap/README.md b/src/app-bootstrap/README.md index c26b8a8..44dec17 100644 --- a/src/app-bootstrap/README.md +++ b/src/app-bootstrap/README.md @@ -42,6 +42,15 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌 잘못 설정된 채로 기동돼 트래픽을 받는 것보다, 기동 시점에 명확한 이유와 함께 멈추는 편이 안전하다. 이 패키지는 그 "빨리·명확하게 실패시키기(fail-fast)"를 담당한다. +### AuthenticationModeCompositionConfig + +`ca-skeleton.security.auth-mode`의 기본값은 `jwt`이며 `jwt|redis-session` 중 정확히 하나만 허용한다. +JWT mode는 `jwtDecoder`가 있어야 하고 Redis session repository/filter가 있으면 기동을 거부한다. +Redis session mode는 반대로 `jwtDecoder`를 거부하고 `redisVersionedSessionRepository`와 +`springSessionRepositoryFilter`가 모두 있어야 한다. 이 검증은 bean name만 확인하므로 bootstrap이 +Spring Session/Redis 구현 타입을 직접 의존하지 않으며, composition 누락과 이중 활성화를 context +refresh 완료 전에 실패시킨다. + ### FlywayProdSafetyValidator - **`prod` 프로파일에서 Flyway 안전장치가 꺼지지 못하도록 런타임에서 강제한다.** Flyway 옵션은 `application.yml`에 안전값으로 고정돼 있지만(`baseline-on-migrate=false`, `out-of-order=false`, @@ -144,7 +153,7 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌 ### SecretSource - **시크릿 해석을 인터페이스 한 겹 뒤로 숨긴 backend seam 이다.** 시크릿이 필요한 코드는 이 인터페이스에만 의존하고, 실제 백엔드(env / Vault / AWS Secrets Manager / GCP Secret Manager)는 - `SecretSourceFactory`가 설정값으로 고른다. `RateLimiter` / `RateLimiterFactory`와 같은 패턴이다. + `SecretSourceFactory`가 설정값으로 고른다. 설정과 구현 선택을 한 factory에 모으는 패턴이다. 백엔드를 추가하는 비용이 "새 `SecretSource` 구현 1개 + `SecretSourceStrategy` enum 값 1개 + factory case 1개"로 고정되고, 소비자(`SecretSourceValidator`, 향후 어댑터)는 전혀 손대지 않는다. - **빈 문자열은 "없음"으로 취급한다.** `resolve`가 blank 값을 `Optional.empty()`로 돌려주지 않으면, @@ -174,7 +183,7 @@ MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌 ### SecretSourceFactory - **유일한 확장 지점을 `switch` 하나로 모았다.** 새 백엔드는 `SecretSourceStrategy` 값 + `SecretSource` 구현 + 이 `switch`의 case 추가로 끝나고 소비자는 바뀌지 않는다. - `RateLimiterFactory`와 같은 형태로, "확장 비용이 어디에 있는가"를 한곳에서 보이게 했다. + factory 한곳에 "확장 비용이 어디에 있는가"를 보이게 했다. ### EnvironmentSecretSource - **기본 백엔드는 Spring `Environment`에서 읽는 것이다.** `ENVIRONMENT` 전략은 시크릿이 env var / diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index d4e2089..32a9067 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -28,6 +28,12 @@ sourceSets { compileClasspath += sourceSets.main.output runtimeClasspath += sourceSets.main.output } + redisCompositionTest { + java.srcDir 'src/redisCompositionTest/java' + resources.srcDir 'src/redisCompositionTest/resources' + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } } configurations { @@ -37,6 +43,9 @@ configurations { sampleOffTestCompileOnly.extendsFrom testCompileOnly sampleOffTestRuntimeOnly.extendsFrom testRuntimeOnly sampleOffTestAnnotationProcessor.extendsFrom testAnnotationProcessor + redisCompositionTestImplementation.extendsFrom testImplementation + redisCompositionTestCompileOnly.extendsFrom testCompileOnly + redisCompositionTestRuntimeOnly.extendsFrom testRuntimeOnly } dependencies { @@ -47,6 +56,7 @@ dependencies { implementation project(':adapter:outbound:messaging') implementation project(':adapter:outbound:cache-redis') implementation project(':adapter:outbound:notification') + implementation project(':adapter:outbound:fileserver') implementation project(':adapter:outbound:httpclient') implementation project(':adapter:outbound:identifier') implementation project(':adapter:inbound:web') @@ -140,6 +150,17 @@ tasks.register('sampleOffTest', Test) { jvmArgs '-Duser.timezone=UTC' } +tasks.register('redisCompositionTest', Test) { + group = 'redis verification' + description = 'Runs Redis provider/role/security-mode composition and zero-side-effect contracts.' + testClassesDirs = sourceSets.redisCompositionTest.output.classesDirs + classpath = sourceSets.redisCompositionTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + // The custom source set compiles the same test corpus, so it follows the repository-wide // warning-only policy already applied to checkstyleTest and spotbugsTest in the root build. tasks.named('checkstyleSampleOffTest') { diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index ba407f5..b38058c 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -1,443 +1,449 @@ # 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. -aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.34=sampleFixture -ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.34=sampleFixture -com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests-util:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture -com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml:classmate:1.7.3=sampleFixture -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.f4b6a3:uuid-creator:6.1.1=sampleFixture,testRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=sampleFixture,spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=sampleFixture -com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okio:okio-jvm:3.6.0=sampleFixture -com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okio:okio:3.6.0=sampleFixture -com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-api:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:6.3.3=sampleFixture -com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.19.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.1.4=sampleFixture -io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.15.12=sampleFixture -io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.15.12=sampleFixture -io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture -io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.15.12=sampleFixture -io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture -io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture -io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.5.12=sampleFixture -io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec-dns:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http2:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec-http:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec-marshalling:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec-protobuf:4.2.7.Final=testRuntimeClasspath io.netty:netty-codec:4.2.7.Final=testRuntimeClasspath -io.netty:netty-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport-classes-epoll:4.2.7.Final=testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture -io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-context:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-common:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-otlp-common:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-otlp:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-extension-trace-propagators:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-common:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-logs:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-metrics:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-trace:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.19=sampleFixture -io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-textformats:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-model:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.smallrye:jandex:3.2.0=sampleFixture io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=sampleFixture -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-core-jakarta:2.2.29=sampleFixture -io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-models-jakarta:2.2.29=sampleFixture -io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.1.0=sampleFixture -jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath -jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.2.0=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture -javax.inject:javax.inject:1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath -org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath +org.apache.kafka:kafka-clients:4.1.1=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=sampleOffTestCompileClasspath,testCompileClasspath -org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.aspectj:aspectjweaver:1.9.25.1=sampleFixture -org.assertj:assertj-core:3.27.6=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.6=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath 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.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.7.2=sampleFixture -org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture -org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture -org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture -org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=sampleFixture -org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture -org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture -org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture -org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture -org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,redisCompositionTestAnnotationProcessor,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-testkit:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath 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=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +org.ow2.asm:asm:9.7.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.11=sampleFixture -org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=sampleFixture -org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=sampleFixture org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springdoc:springdoc-openapi-starter-common:2.8.6=sampleFixture -org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=sampleFixture -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot:3.5.16=sampleFixture -org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.cloud:spring-cloud-context:4.1.4=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.springframework.data:spring-data-commons:3.5.13=sampleFixture -org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-jpa:3.5.13=sampleFixture -org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-keyvalue:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.data:spring-data-redis:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-core:6.5.10=sampleFixture -org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture -org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.retry:spring-retry:2.0.13=sampleFixture org.springframework.security:spring-security-config:6.5.11=sampleFixture -org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-core:6.5.11=sampleFixture -org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-crypto:6.5.11=sampleFixture -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:6.5.11=sampleFixture -org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.session:spring-session-data-redis:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-aop:6.2.19=sampleFixture -org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:6.2.19=sampleFixture -org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:6.2.19=sampleFixture -org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-context:6.2.19=sampleFixture -org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:6.2.19=sampleFixture -org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:6.2.19=sampleFixture -org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jcl:6.2.19=sampleFixture org.springframework:spring-jdbc:6.2.19=sampleFixture -org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:6.2.19=sampleFixture -org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:6.2.19=sampleFixture -org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-oxm:7.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:6.2.19=sampleFixture -org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:6.2.19=sampleFixture -org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:6.2.19=sampleFixture -org.springframework:spring-webmvc:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-websocket:7.0.1=sampleOffTestCompileClasspath,testCompileClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-websocket:7.0.1=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.4=sampleFixture -org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath @@ -468,7 +474,7 @@ software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java new file mode 100644 index 0000000..1aec43d --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfig.java @@ -0,0 +1,56 @@ +package dev.caskeleton.bootstrap.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientActivationResolver; +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration; +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfigurationBinder; +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientReadinessCardRegistry; +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpOperationCatalogRegistry; +import dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * HTTP capability composition root. + * + *

Only inert configuration, registries, and a sanitized descriptor are registered in the current + * zero-binding implementation. No transport/provider configuration is imported. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientCompositionConfig { + + @Bean + @ConditionalOnMissingBean + HttpClientCanonicalConfiguration httpClientCanonicalConfiguration(Environment environment) { + return new HttpClientCanonicalConfigurationBinder(Binder.get(environment)).bind(); + } + + @Bean + @ConditionalOnMissingBean + HttpOperationCatalogRegistry httpOperationCatalogRegistry() { + return HttpOperationCatalogRegistry.empty(); + } + + @Bean + @ConditionalOnMissingBean + HttpClientReadinessCardRegistry httpClientReadinessCardRegistry() { + return HttpClientReadinessCardRegistry.current(); + } + + @Bean + @ConditionalOnMissingBean + HttpClientActivationResolver httpClientActivationResolver() { + return new HttpClientActivationResolver(); + } + + @Bean + ResolvedHttpClientCapability resolvedHttpClientCapability( + HttpClientCanonicalConfiguration configuration, + HttpOperationCatalogRegistry catalogs, + HttpClientReadinessCardRegistry readinessCards, + HttpClientActivationResolver resolver) { + return resolver.resolve(configuration, catalogs, readinessCards); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java index bea76ce..3900dc2 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java @@ -3,6 +3,7 @@ package dev.caskeleton.bootstrap.idempotency; import dev.caskeleton.application.idempotency.IdempotencyExecutor; import dev.caskeleton.application.idempotency.IdempotencyStorePort; import java.time.Clock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @@ -21,6 +22,10 @@ public class IdempotencyConfig { } @Bean + @ConditionalOnProperty( + name = "ca-skeleton.capabilities.idempotency.provider", + havingValue = "jdbc", + matchIfMissing = true) public IdempotencyExecutor idempotencyExecutor( IdempotencyStorePort store, Clock clock, IdempotencySettings properties) { return new IdempotencyExecutor(store, clock, properties.ttl()); 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 new file mode 100644 index 0000000..58877d2 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSelectionConfig.java @@ -0,0 +1,69 @@ +package dev.caskeleton.bootstrap.idempotency; + +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.IdempotencyExecutorV2; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Fail-fast exclusivity guard for the JDBC V1 and Redis V2 request-replay providers. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(IdempotencyProviderSettings.class) +public class IdempotencyProviderSelectionConfig { + + @Bean + SmartInitializingSingleton idempotencyProviderExclusivity( + IdempotencyProviderSettings settings, + ObjectProvider jdbcStores, + ObjectProvider jdbcExecutors, + ObjectProvider redisStores, + ObjectProvider redisExecutors) { + return () -> { + int jdbcStoreCount = count(jdbcStores); + int jdbcExecutorCount = count(jdbcExecutors); + int redisStoreCount = count(redisStores); + int redisExecutorCount = count(redisExecutors); + switch (settings.provider()) { + case DISABLED -> + requireCounts( + jdbcStoreCount, jdbcExecutorCount, redisStoreCount, redisExecutorCount, 0, 0, 0, 0); + case JDBC -> + requireCounts( + jdbcStoreCount, jdbcExecutorCount, redisStoreCount, redisExecutorCount, 1, 1, 0, 0); + case REDIS -> + requireCounts( + jdbcStoreCount, jdbcExecutorCount, redisStoreCount, redisExecutorCount, 0, 0, 1, 1); + default -> + throw new IllegalStateException( + "Unsupported idempotency provider: " + settings.provider()); + } + }; + } + + private static int count(ObjectProvider beans) { + return Math.toIntExact(beans.stream().count()); + } + + private static void requireCounts( + int jdbcStores, + int jdbcExecutors, + int redisStores, + int redisExecutors, + int expectedJdbcStores, + int expectedJdbcExecutors, + int expectedRedisStores, + int expectedRedisExecutors) { + if (jdbcStores != expectedJdbcStores + || jdbcExecutors != expectedJdbcExecutors + || redisStores != expectedRedisStores + || redisExecutors != expectedRedisExecutors) { + throw new IllegalStateException( + "Idempotency provider selection is ambiguous or incomplete: exactly the selected " + + "JDBC V1 or Redis V2 store/executor pair must be active"); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSettings.java new file mode 100644 index 0000000..bb9a948 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyProviderSettings.java @@ -0,0 +1,20 @@ +package dev.caskeleton.bootstrap.idempotency; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Exact provider selector; provider precedence is never inferred from the classpath. */ +@ConfigurationProperties(prefix = "ca-skeleton.capabilities.idempotency") +public record IdempotencyProviderSettings(Provider provider) { + + @ConstructorBinding + public IdempotencyProviderSettings { + provider = provider == null ? Provider.JDBC : provider; + } + + public enum Provider { + DISABLED, + JDBC, + REDIS + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentCredentialMaterialProvider.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentCredentialMaterialProvider.java new file mode 100644 index 0000000..943076e --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentCredentialMaterialProvider.java @@ -0,0 +1,42 @@ +package dev.caskeleton.bootstrap.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.bootstrap.runtime.SecretSource; +import java.time.Instant; + +/** + * Strict {@code secret://environment/ENV_KEY} bridge for canonical Redis material. + * + *

The environment backend has no change-event stream. Rotation therefore requires process + * restart or an explicit runtime recomposition initiated by an operator. + */ +public final class RedisEnvironmentCredentialMaterialProvider + implements RedisCredentialMaterialProvider { + + private static final int MAXIMUM_CREDENTIAL_CHARS = 16_384; + private static final String VERSION = "environment-restart-v1"; + private final RedisEnvironmentMaterialResolver resolver; + + public RedisEnvironmentCredentialMaterialProvider(SecretSource secretSource) { + this.resolver = new RedisEnvironmentMaterialResolver(secretSource); + } + + @Override + public VersionedRedisCredentialMaterial resolve(RedisSecretReference reference) { + String value = resolver.resolve(reference, MAXIMUM_CREDENTIAL_CHARS); + char[] mutable = value.toCharArray(); + try { + return new VersionedRedisCredentialMaterial( + VERSION, Instant.MAX, DestroyableRedisSecret.from(mutable)); + } finally { + java.util.Arrays.fill(mutable, '\0'); + } + } + + public RedisEnvironmentMaterialProviderDescriptor descriptor() { + return RedisEnvironmentMaterialProviderDescriptor.restartOrExplicitRecomposition(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialConfig.java new file mode 100644 index 0000000..f32df68 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialConfig.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.redis; + +import dev.caskeleton.bootstrap.runtime.SecretSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Registers the environment-backed material bridge without resolving any secret eagerly. */ +@Configuration(proxyBeanMethods = false) +public class RedisEnvironmentMaterialConfig { + + @Bean + RedisEnvironmentCredentialMaterialProvider redisEnvironmentCredentialMaterialProvider( + SecretSource secretSource) { + return new RedisEnvironmentCredentialMaterialProvider(secretSource); + } + + @Bean + RedisEnvironmentTrustMaterialProvider redisEnvironmentTrustMaterialProvider( + SecretSource secretSource) { + return new RedisEnvironmentTrustMaterialProvider(secretSource); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderDescriptor.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderDescriptor.java new file mode 100644 index 0000000..5835e60 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderDescriptor.java @@ -0,0 +1,11 @@ +package dev.caskeleton.bootstrap.redis; + +/** Honest operational capability descriptor for the environment-backed Redis material bridge. */ +public record RedisEnvironmentMaterialProviderDescriptor( + String provider, boolean changeEventsSupported, String refreshMode) { + + static RedisEnvironmentMaterialProviderDescriptor restartOrExplicitRecomposition() { + return new RedisEnvironmentMaterialProviderDescriptor( + "environment", false, "restart-or-explicit-runtime-recomposition"); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialResolver.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialResolver.java new file mode 100644 index 0000000..f0a7479 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialResolver.java @@ -0,0 +1,73 @@ +package dev.caskeleton.bootstrap.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.bootstrap.runtime.SecretSource; +import java.util.Objects; +import java.util.Set; + +/** Shared strict parser/resolver for the two environment material provider interfaces. */ +final class RedisEnvironmentMaterialResolver { + + private static final String PREFIX = "secret://environment/"; + private static final Set ALLOWED_KEYS = + Set.of( + "APP_CACHE_REDIS_PASSWORD", + "APP_CACHE_REDIS_KEY_HMAC_SECRET", + "APP_CACHE_REDIS_TRUST_PEM", + "APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET", + "APP_LEASE_REDIS_KEY_HMAC_SECRET", + "APP_RATE_LIMIT_REDIS_PASSWORD", + "APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET", + "APP_RATE_LIMIT_REDIS_TRUST_PEM", + "APP_SESSION_REDIS_PASSWORD", + "APP_SESSION_REDIS_KEY_HMAC_SECRET", + "APP_SESSION_REDIS_TRUST_PEM"); + + private final SecretSource secretSource; + + RedisEnvironmentMaterialResolver(SecretSource secretSource) { + this.secretSource = Objects.requireNonNull(secretSource, "secretSource must be non-null"); + } + + String resolve(RedisSecretReference reference, int maximumLength) { + Objects.requireNonNull(reference, "reference must be non-null"); + String key = parseAllowedKey(reference.valueForResolution()); + try { + String value = + secretSource.resolve(key).orElseThrow(RedisEnvironmentMaterialResolver::materialFailure); + if (value.isBlank() || value.length() > maximumLength) { + throw materialFailure(); + } + return value; + } catch (RedisEnvironmentMaterialException exception) { + throw exception; + } catch (RuntimeException ignored) { + throw materialFailure(); + } + } + + private static String parseAllowedKey(String reference) { + if (reference == null + || !reference.startsWith(PREFIX) + || reference.length() <= PREFIX.length()) { + throw materialFailure(); + } + String key = reference.substring(PREFIX.length()); + if (!key.matches("[A-Z][A-Z0-9_]{0,127}") || !ALLOWED_KEYS.contains(key)) { + throw materialFailure(); + } + return key; + } + + static RedisEnvironmentMaterialException materialFailure() { + return new RedisEnvironmentMaterialException( + "Canonical Redis environment material resolution failed"); + } + + static final class RedisEnvironmentMaterialException extends IllegalStateException { + + private RedisEnvironmentMaterialException(String message) { + super(message); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentTrustMaterialProvider.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentTrustMaterialProvider.java new file mode 100644 index 0000000..e87c9d6 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentTrustMaterialProvider.java @@ -0,0 +1,42 @@ +package dev.caskeleton.bootstrap.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisPem; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.bootstrap.runtime.SecretSource; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; + +/** Environment-backed, restart-only Redis trust material provider. */ +public final class RedisEnvironmentTrustMaterialProvider implements RedisTrustMaterialProvider { + + private static final int MAXIMUM_TRUST_BYTES = 1_048_576; + private static final String VERSION = "environment-restart-v1"; + private final RedisEnvironmentMaterialResolver resolver; + + public RedisEnvironmentTrustMaterialProvider(SecretSource secretSource) { + this.resolver = new RedisEnvironmentMaterialResolver(secretSource); + } + + @Override + public VersionedRedisTrustMaterial resolve(RedisSecretReference reference) { + String value = resolver.resolve(reference, MAXIMUM_TRUST_BYTES); + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length > MAXIMUM_TRUST_BYTES) { + Arrays.fill(encoded, (byte) 0); + throw RedisEnvironmentMaterialResolver.materialFailure(); + } + try { + return new VersionedRedisTrustMaterial( + VERSION, Instant.MAX, DestroyableRedisPem.from(encoded)); + } finally { + Arrays.fill(encoded, (byte) 0); + } + } + + public RedisEnvironmentMaterialProviderDescriptor descriptor() { + return RedisEnvironmentMaterialProviderDescriptor.restartOrExplicitRecomposition(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java index 7d4ce2b..51ce43a 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java @@ -32,6 +32,12 @@ public class SecretSourceValidator implements SmartInitializingSingleton { "APP_EXTERNAL_API_KEY", "APP_CACHE_REDIS_PASSWORD", "APP_CACHE_REDIS_KEY_HMAC_SECRET", + "APP_RATE_LIMIT_REDIS_PASSWORD", + "APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET", + "APP_SESSION_REDIS_PASSWORD", + "APP_SESSION_REDIS_KEY_HMAC_SECRET", + "APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET", + "APP_LEASE_REDIS_KEY_HMAC_SECRET", "APP_PRIVACY_PSEUDONYMIZATION_SALT"); private final ConfigurableEnvironment environment; @@ -82,6 +88,18 @@ public class SecretSourceValidator implements SmartInitializingSingleton { } List missing = new ArrayList<>(); for (String key : REQUIRED_PROD_SECRETS) { + if (isRateLimitSecret(key) && !isRedisRateLimitProviderSelected()) { + continue; + } + if (isSessionRedisMaterial(key) && !isRedisSessionSelected()) { + continue; + } + if (isIdempotencyRedisMaterial(key) && !isRedisIdempotencyProviderSelected()) { + continue; + } + if (isLeaseRedisMaterial(key) && !isRedisLeaseProviderSelected()) { + continue; + } if (secretSource.resolve(key).isEmpty()) { missing.add(key); } @@ -95,6 +113,45 @@ public class SecretSourceValidator implements SmartInitializingSingleton { } } + private static boolean isRateLimitSecret(String key) { + return key.startsWith("APP_RATE_LIMIT_REDIS_"); + } + + private boolean isRedisRateLimitProviderSelected() { + return "redis" + .equalsIgnoreCase( + environment.getProperty("ca-skeleton.capabilities.rate-limit.provider", "disabled")); + } + + private static boolean isSessionRedisMaterial(String key) { + return key.startsWith("APP_SESSION_REDIS_"); + } + + private boolean isRedisSessionSelected() { + return "redis-session" + .equalsIgnoreCase(environment.getProperty("ca-skeleton.security.auth-mode", "jwt")); + } + + private static boolean isIdempotencyRedisMaterial(String key) { + return key.startsWith("APP_IDEMPOTENCY_REDIS_"); + } + + private boolean isRedisIdempotencyProviderSelected() { + return "redis" + .equalsIgnoreCase( + environment.getProperty("ca-skeleton.capabilities.idempotency.provider", "jdbc")); + } + + private static boolean isLeaseRedisMaterial(String key) { + return key.startsWith("APP_LEASE_REDIS_"); + } + + private boolean isRedisLeaseProviderSelected() { + return "redis" + .equalsIgnoreCase( + environment.getProperty("ca-skeleton.capabilities.lease.provider", "disabled")); + } + private boolean isProdActive() { // Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match. for (String profile : environment.getActiveProfiles()) { diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfig.java new file mode 100644 index 0000000..8c320c4 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfig.java @@ -0,0 +1,151 @@ +package dev.caskeleton.bootstrap.runtime.redis; + +import dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalConfig; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Role; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.RoleHealth; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** Maps the framework-neutral Redis role snapshot into the bootstrap-owned health framework. */ +@Configuration(proxyBeanMethods = false) +public class RedisHealthContributorConfig { + + @Bean("redisRequired") + @Conditional(RequiredRedisRoleCondition.class) + HealthIndicator redisRequired(RedisHealthSnapshotProvider snapshots, Environment environment) { + Set expected = expectedRequiredRoles(environment); + return () -> requiredHealth(snapshots, expected); + } + + @Bean("redisOptional") + @Conditional(CacheRedisRoleCondition.class) + HealthIndicator redisOptional(RedisHealthSnapshotProvider snapshots) { + return () -> optionalCacheHealth(snapshots); + } + + private static Health requiredHealth(RedisHealthSnapshotProvider snapshots, Set expected) { + try { + List roles = + snapshots.snapshot().roles().stream() + .filter(role -> expected.contains(role.role()) && role.required()) + .sorted(Comparator.comparing(RoleHealth::role)) + .toList(); + boolean complete = + roles.stream() + .map(RoleHealth::role) + .collect(java.util.stream.Collectors.toSet()) + .containsAll(expected); + boolean available = + complete && roles.stream().allMatch(role -> role.state() == State.AVAILABLE); + Health.Builder health = available ? Health.up() : Health.down(); + return health + .withDetail("state", available ? "AVAILABLE" : "UNAVAILABLE") + .withDetail("roles", details(roles)) + .withDetail("missingRoles", missing(expected, roles)) + .build(); + } catch (RuntimeException exception) { + return Health.down() + .withDetail("state", "UNAVAILABLE") + .withDetail("reason", "SNAPSHOT_UNAVAILABLE") + .build(); + } + } + + private static Health optionalCacheHealth(RedisHealthSnapshotProvider snapshots) { + try { + List roles = + snapshots.snapshot().roles().stream() + .filter(role -> role.role() == Role.CACHE && !role.required()) + .toList(); + boolean available = roles.size() == 1 && roles.getFirst().state() == State.AVAILABLE; + return Health.up() + .withDetail("state", available ? "AVAILABLE" : "DEGRADED") + .withDetail("roles", details(roles)) + .withDetail("missingRoles", roles.isEmpty() ? List.of(Role.CACHE.name()) : List.of()) + .build(); + } catch (RuntimeException exception) { + return Health.up() + .withDetail("state", "DEGRADED") + .withDetail("reason", "SNAPSHOT_UNAVAILABLE") + .build(); + } + } + + private static List> details(List roles) { + List> result = new ArrayList<>(roles.size()); + for (RoleHealth role : roles) { + Map detail = new LinkedHashMap<>(); + detail.put("role", role.role().name()); + detail.put("capabilities", role.capabilities().stream().map(Enum::name).sorted().toList()); + detail.put("state", role.state().name()); + detail.put("reason", role.reason().name()); + detail.put("semanticObservedAt", role.semanticObservedAt().toString()); + detail.put("semanticAgeMillis", role.semanticAgeMillis()); + detail.put("semanticStale", role.semanticStale()); + detail.put("expectedEviction", role.expectedEviction().name()); + detail.put("evictionAttestation", role.evictionAttestation().name()); + detail.put("externalEvictionAttestation", "INCOMPLETE"); + result.add(Map.copyOf(detail)); + } + return List.copyOf(result); + } + + private static List missing(Set expected, List actual) { + EnumSet missing = EnumSet.noneOf(Role.class); + missing.addAll(expected); + actual.forEach(role -> missing.remove(role.role())); + return missing.stream().map(Enum::name).toList(); + } + + private static Set expectedRequiredRoles(Environment environment) { + EnumSet roles = EnumSet.noneOf(Role.class); + if (selected(environment, RedisRole.COORDINATION)) { + roles.add(Role.COORDINATION); + } + if (selected(environment, RedisRole.SESSION)) { + roles.add(Role.SESSION); + } + return Set.copyOf(roles); + } + + private static boolean selected(Environment environment, RedisRole role) { + return !RedisCanonicalConfig.selectedCapabilities(environment) + .getOrDefault(role, Set.of()) + .isEmpty(); + } + + static final class RequiredRedisRoleCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + Environment environment = context.getEnvironment(); + return selected(environment, RedisRole.COORDINATION) + || selected(environment, RedisRole.SESSION); + } + } + + static final class CacheRedisRoleCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return selected(context.getEnvironment(), RedisRole.CACHE); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java new file mode 100644 index 0000000..784c939 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java @@ -0,0 +1,48 @@ +package dev.caskeleton.bootstrap.security; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Fails startup when JWT and Redis Session infrastructure are both present or both absent. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(AuthenticationModeSettings.class) +public class AuthenticationModeCompositionConfig { + + @Bean + SmartInitializingSingleton authenticationModeCompositionValidator( + AuthenticationModeSettings settings, ListableBeanFactory beans) { + return () -> validate(settings.authMode(), beans); + } + + static void validate( + AuthenticationModeSettings.AuthenticationMode mode, ListableBeanFactory beans) { + List contradictions = new ArrayList<>(); + boolean jwtDecoder = beans.containsBean("jwtDecoder"); + boolean sessionRepository = beans.containsBean("redisVersionedSessionRepository"); + boolean sessionFilter = beans.containsBean("springSessionRepositoryFilter"); + if (mode == AuthenticationModeSettings.AuthenticationMode.JWT) { + if (!jwtDecoder) { + contradictions.add("jwtDecoder is absent"); + } + if (sessionRepository || sessionFilter) { + contradictions.add("Redis Session repository/filter is active"); + } + } else { + if (jwtDecoder) { + contradictions.add("jwtDecoder is active"); + } + if (!sessionRepository || !sessionFilter) { + contradictions.add("Redis Session repository/filter is incomplete"); + } + } + if (!contradictions.isEmpty()) { + throw new IllegalStateException( + "Authentication mode composition is not exclusive for " + mode + ": " + contradictions); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeSettings.java new file mode 100644 index 0000000..40a72d0 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeSettings.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** Composition-root authority for the exclusive application authentication mode. */ +@ConfigurationProperties(prefix = "ca-skeleton.security") +public record AuthenticationModeSettings(AuthenticationMode authMode) { + + @ConstructorBinding + public AuthenticationModeSettings(AuthenticationMode authMode) { + this.authMode = authMode == null ? AuthenticationMode.JWT : authMode; + } + + public enum AuthenticationMode { + JWT, + REDIS_SESSION + } +} diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index c5b3329..cbb63fb 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -233,6 +233,9 @@ management: health: # D8: never expose health details to unauthenticated callers. show-details: when-authorized + # redisRequired exists only when a correctness role is bound. Keep the static group + # fail-closed for known names while allowing an absent conditional contributor. + validate-group-membership: false # feature-runtime-health-lifecycle-contract: expose the Kubernetes-ready # liveness/readiness/startup probe paths. probes: @@ -244,11 +247,11 @@ management: liveness: include: livenessState # Readiness: ready to serve traffic AND all REQUIRED dependencies up. - # Required: db (primary DB — auto-contributed by Spring Boot DataSource). - # Optional: cache / broker / notification adapters are NOT in this group - # (they are conditional or optional per the dependency taxonomy). + # Required: db and redisRequired (only COORDINATION/SESSION role bindings). + # redisOptional is deliberately excluded: a CACHE outage is reported as degraded detail + # but never turns a healthy JVM or an otherwise-ready pod unavailable. readiness: - include: readinessState,db + include: readinessState,db,redisRequired # Startup: startup/migration validation complete. # readinessState acts as the startup completion gate — it flips UP only # after the context is fully initialized (Flyway migration included). @@ -299,6 +302,126 @@ logging: # Module-scoped knobs. Each block is bound into a *Settings @ConfigurationProperties # record in the corresponding module, which is where allowed-value validation lives. ca-skeleton: + # Canonical HTTP capability activation. Bindings are the sole activation SSOT: provider + # definitions alone are inert, and the current NOT_IMPLEMENTED readiness card rejects ACTIVE + # before any client/executor/pool resource can be created. + capabilities: + cache: + # Canonical semantic cache activation. Disabled by default; "redis" requires an active + # ca-skeleton.providers.redis.roles.cache binding and resolves HMAC material by reference. + bindings: + default: ${APP_CACHE_CANONICAL_DEFAULT_PROVIDER:disabled} + regions: + default: + key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} + semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} + hash-key-version: 1 + key-version: 1 + policy-revision: canonical-default-r1 + positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:240s} + positive-hard-ttl: ${APP_CACHE_DEFAULT_TTL:300s} + negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} + ttl-jitter: ${APP_CACHE_REDIS_TTL_JITTER:0.10} + maximum-value-bytes: 61440 + l1: + enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} + maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} + maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} + maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} + time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} + generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} + invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} + idempotency: + # disabled | jdbc | redis. JDBC is the existing V1 provider; Redis is the owner-safe V2 + # provider. They are mutually exclusive and no V1-to-V2 facade is inferred. + provider: ${APP_IDEMPOTENCY_PROVIDER:jdbc} + key-hmac-secret-reference: secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT:local} + hash-key-version: 1 + key-version: 1 + processing-lease: ${APP_IDEMPOTENCY_PROCESSING_LEASE:30s} + replay-ttl: ${APP_IDEMPOTENCY_TTL:24h} + failure-retention: ${APP_IDEMPOTENCY_FAILURE_RETENTION:24h} + response-codec-id: json-v2 + policy-revision: request-replay-v2 + lease: + # disabled | redis. This is EFFICIENCY_ONLY and never supplies fencing. + provider: ${APP_LEASE_PROVIDER:disabled} + key-hmac-secret-reference: secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT:local} + hash-key-version: 1 + key-version: 1 + drift-budget: ${APP_LEASE_REDIS_DRIFT_BUDGET:10ms} + rate-limit: + # disabled | redis. This is the sole outbound provider activation selector. + provider: ${APP_RATE_LIMIT_PROVIDER:disabled} + failure-policy: ${APP_RATE_LIMIT_FAILURE_POLICY:fail-closed} + default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default} + failure-retry-after: ${APP_RATE_LIMIT_FAILURE_RETRY_AFTER:100ms} + hash-key-version: ${APP_RATE_LIMIT_HASH_KEY_VERSION:1} + key-version: ${APP_RATE_LIMIT_KEY_VERSION:1} + key-hmac-secret-reference: secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_RATE_LIMIT_REDIS_NAMESPACE_ENVIRONMENT:local} + policies: + api-default: + revision: ${APP_RATE_LIMIT_POLICY_REVISION:v1} + algorithm: ${APP_RATE_LIMIT_ALGORITHM:sliding-counter} + limit: ${APP_RATE_LIMIT_LIMIT:100} + window: ${APP_RATE_LIMIT_WINDOW:1s} + capacity: ${APP_RATE_LIMIT_CAPACITY:100} + refill-tokens: ${APP_RATE_LIMIT_REFILL_TOKENS:100} + refill-period: ${APP_RATE_LIMIT_REFILL_PERIOD:1s} + maximum-cost: ${APP_RATE_LIMIT_MAXIMUM_COST:10} + cleanup-grace: ${APP_RATE_LIMIT_CLEANUP_GRACE:5s} + maximum-clock-regression: ${APP_RATE_LIMIT_MAXIMUM_CLOCK_REGRESSION:250ms} + security: + redis-session: + key-hmac-secret-reference: secret://environment/APP_SESSION_REDIS_KEY_HMAC_SECRET + namespace-application: ${APP_NAME:ca-skeleton} + namespace-environment: ${APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT:local} + hash-key-version: 1 + key-version: 1 + idle-timeout: ${APP_SESSION_IDLE_TIMEOUT:30m} + absolute-lifetime: ${APP_SESSION_ABSOLUTE_LIFETIME:8h} + touch-interval: ${APP_SESSION_TOUCH_INTERVAL:1m} + tombstone-time-to-live: ${APP_SESSION_TOMBSTONE_TTL:5m} + maximum-envelope-bytes: ${APP_SESSION_MAXIMUM_ENVELOPE_BYTES:32768} + maximum-attributes: ${APP_SESSION_MAXIMUM_ATTRIBUTES:64} + maximum-scalar-bytes: ${APP_SESSION_MAXIMUM_SCALAR_BYTES:8192} + http-client: + expected-state: DISABLED + bindings: {} + providers: + http-client: {} + redis: + # Definitions alone are inert. Environment-specific configuration must bind roles. + legacy-migration-enabled: false + deployments: {} + roles: {} + runtime: + client-name: canonical-redis + connect-timeout: 2s + tls-handshake-timeout: 3s + acquire-timeout: 2s + command-timeout: 2s + overall-timeout: 5s + shutdown-timeout: 3s + maximum-queued-commands: 64 + cluster-maximum-redirects: 5 + cluster-topology-refresh-period: 30s + maximum-in-flight-commands: 64 + maximum-command-bytes: 65536 + maximum-in-flight-bytes: 4194304 + route-drain-timeout: 6s + default-write-ttl: 5m + sentinel-discovery-refresh-period: ${APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD:30s} + semantic-probe-minimum-interval: ${APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL:5s} + semantic-probe-maximum-staleness: ${APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS:15s} bootstrap: # required, non-blank — startup fails if blank (see BootstrapSettings) app-name: ${APP_NAME} @@ -326,18 +449,6 @@ ca-skeleton: # prefix "/v1" (major-version path, AIP-185); override via env, or set "" for # no prefix. The supplemental "X-Api-Version" header never overrides the path. api-base-path: ${PRESENTATION_API_BASE_PATH:/v1} - rate-limit: - # feature-rate-limit-idempotency-contract D1/§G. enabled is env-driven - # (restart-only); limit/window are the fixed-window mechanism's literal tuning - # parameters (no env key — UNSUPPORTED_IMPL per-key counter, single-node D5). - enabled: ${APP_RATE_LIMIT_ENABLED} - limit: 100 - window: 1s - # RateLimiter strategy: fixed-window (default) | (extend: sliding-window | token-bucket) - algorithm: fixed-window - # Client IP source for unauthenticated rate-limit keys: - # remote-addr-only (safe default) | forwarded-headers-trusted (only behind trusted ingress/LB) - client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only} idempotency: # feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h, # validated in IdempotencyProperties); reaper-interval is literal operational tuning. @@ -372,12 +483,22 @@ ca-skeleton: # read also by adapter-persistence OutboxReaper via ${ca-skeleton.outbox.published-retention:P7D} published-retention: P7D security: - # REQUIRED; startup fails if blank + # jwt | redis-session; the bootstrap composition validator rejects mixed infrastructure. + auth-mode: ${APP_SECURITY_AUTH_MODE:jwt} + # Required only in jwt mode. issuer-uri: ${APP_SECURITY_JWT_ISSUER} # blank to skip audience check audience: ${APP_SECURITY_JWT_AUDIENCE} # comma-separated list (Spring binds to List) public-paths: ${SECURITY_PUBLIC_PATHS} + session: + cookie-name: ${APP_SESSION_COOKIE_NAME:CA_SESSION} + secure: ${APP_SESSION_COOKIE_SECURE:true} + http-only: ${APP_SESSION_COOKIE_HTTP_ONLY:true} + same-site: ${APP_SESSION_COOKIE_SAME_SITE:Lax} + path: ${APP_SESSION_COOKIE_PATH:/} + csrf-cookie-name: ${APP_SESSION_CSRF_COOKIE_NAME:XSRF-TOKEN} + csrf-header-name: ${APP_SESSION_CSRF_HEADER_NAME:X-XSRF-TOKEN} authz: # feature-authentication-authorization-contract D2/D3/D8: app-side role→permission # mapping (the default source; IdP-issued permission claims are an out-of-scope @@ -466,6 +587,42 @@ ca-skeleton: # (which needs its project-supplied integration client bean). Domain namespace, NOT a # generic `app.adapter.*` prefix (branch-note §Audit A1). Env keys are the registry SSOT. app: + # Fileserver R2 exact destination/provider composition. Disabled by default: while false, + # these blank attestation placeholders do not create directories, probe a filesystem, or + # contribute FilePublicationPort. Enabling fails closed unless every local-persistent + # attestation value matches the pre-provisioned root. No implicit local fallback exists. + fileserver: + enabled: ${APP_FILESERVER_ENABLED:false} + destinations: + local-export: + provider-ref: local-primary + required-publication: unique-atomic-create + required-durability: file-and-directory-sync + maximum-rows: 1000000 + maximum-encoded-bytes: 1073741824 + providers: + local-primary: + # local-persistent is the only implemented/qualified R2 provider. + # shared-mounted/NFS and SFTP settings must not be added before their providers exist. + type: local-persistent + root-directory: ${APP_FILESERVER_LOCAL_ROOT:} + auto-create: false + strict-path-security: true + expected-file-store-name: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME:} + expected-file-store-type: ${APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE:} + mount-sentinel-name: .ca-fileserver-volume + mount-sentinel-sha256: ${APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256:} + expected-owner: ${APP_FILESERVER_LOCAL_EXPECTED_OWNER:} + maximum-root-mode: "0750" + rate-limit: + # Inbound HTTP enforcement is a separate axis from outbound provider activation. + # enabled=true with no exact EdgeRateLimitPort fails fast; it never installs a local fallback. + enabled: ${APP_RATE_LIMIT_ENABLED:false} + default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default} + hash-key-version: ${APP_RATE_LIMIT_HASH_KEY_VERSION:1} + caller-deadline-budget: 2s + # remote-addr-only | forwarded-headers-trusted (trusted ingress only) + client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only} cache: redis: # true | false (boolean_strict). Redis cache adapter on/off. @@ -481,11 +638,24 @@ app: maximum-queued-commands: ${APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS:8} maximum-in-flight-bytes: ${APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES:16777216} positive-ttl: ${APP_CACHE_DEFAULT_TTL:300s} + # Blank derives 80% of positive-ttl in typed settings. + positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:} negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} + ttl-jitter: ${APP_CACHE_REDIS_TTL_JITTER:0.10} + minimum-hard-ttl: ${APP_CACHE_REDIS_MINIMUM_HARD_TTL:1s} namespace-application: ${APP_NAME:ca-skeleton} namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} maximum-value-bytes: ${APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES:1048576} + # Optional cache-only L1. Never reuse for session, idempotency or strict rate-limit state. + l1: + enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} + maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} + maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} + maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} + time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} + generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} + invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} # Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default — # forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis. # A binding to a backend that is not enabled fails startup (Layer 3 moved to router). @@ -504,44 +674,3 @@ app: provider: ${APP_NOTIFICATION_SLACK_PROVIDER} email: provider: ${APP_NOTIFICATION_EMAIL_PROVIDER} - # --------------------------------------------------------------------------- - # feature-outbound-http-client-baseline D5/D3/D7 - # Registry SSOT: docs/registries/env-keys.yaml (APP_OUTBOUND_HTTP_* rows 489–570) - # Bound into OutboundHttpSettings @ConfigurationProperties(prefix = "app.outbound.http"). - # --------------------------------------------------------------------------- - outbound: - http: - # duration (e.g. 2s). REQUIRED — non-zero (spring_duration_shorthand_non_zero). - connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT} - # duration (e.g. 5s). REQUIRED — non-zero. - read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT} - # duration (e.g. 10s). REQUIRED — non-zero (deadline budget for the whole call incl. retries). - global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT} - # Per-client live worker bound; timed-out non-cooperative workers retain a slot until exit. - maximum-in-flight-calls: ${APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS:128} - # true | false (boolean_strict). Resilience4j retry — default disabled (D3). - retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} - # retry 튜닝 (retry-enabled=true 일 때 적용). 기본값 = 기존 하드코딩 동작 보존. - retry: - # int >= 1 (positive_int). 총 시도 횟수(최초 시도 포함). - max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} - # duration (spring_duration_shorthand_non_zero). exponential backoff 시작 간격. - initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms} - # double >= 1.0 (double_ge_1). exponential backoff 배수. - backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0} - # true | false (boolean_strict). Resilience4j circuit breaker — default disabled. - circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false} - # circuit-breaker 튜닝 (circuit-breaker-enabled=true 일 때 적용). 기본값 = Resilience4j ofDefaults(). - circuit-breaker: - # float in (0, 100] (float_in_0_exclusive_to_100). open 전환 실패율 임계치(%). - failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50} - # int >= 1 (positive_int). COUNT_BASED sliding window 크기. - sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100} - # int >= 1 (positive_int). 실패율 계산을 시작하는 최소 호출 수. - minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100} - # duration (spring_duration_shorthand_non_zero). open 상태 유지 시간. - wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s} - # int >= 1 (positive_int). half-open 상태에서 허용하는 시험 호출 수. - permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10} - # data size (e.g. 10MB). Streaming threshold — buffered reads above this fail (D7). - response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB} diff --git a/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCoordinationRuntimeCompositionContractTest.java b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCoordinationRuntimeCompositionContractTest.java new file mode 100644 index 0000000..0f3a7f2 --- /dev/null +++ b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCoordinationRuntimeCompositionContractTest.java @@ -0,0 +1,167 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettingsFactory; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRoleBinding; +import dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class RedisCoordinationRuntimeCompositionContractTest { + + private static final RedisClientRuntimeSettings CLIENT_SETTINGS = + new RedisClientRuntimeSettings( + "composition-test", + Duration.ofMillis(100), + Duration.ofMillis(100), + Duration.ofMillis(200), + Duration.ofMillis(500), + Duration.ofMillis(300), + 8, + 3, + Duration.ofSeconds(5)); + + @Test + void twoCoordinationCapabilitiesCreateOneRuntimeAndOneRouter() { + MockEnvironment environment = + new MockEnvironment() + .withProperty("ca-skeleton.capabilities.rate-limit.provider", "redis") + .withProperty("ca-skeleton.capabilities.idempotency.provider", "redis"); + Map> capabilities = + RedisCanonicalConfig.selectedCapabilities(environment); + AtomicInteger runtimeBuilds = new AtomicInteger(); + + try (RedisCanonicalRoleRegistry registry = + new RedisCanonicalRoleRegistry( + new RedisDeploymentSettingsFactory() + .compileActive(declaredProvider(), selectedRoles(capabilities)), + CLIENT_SETTINGS, + 4, + 16_384, + 1_048_576, + Duration.ofSeconds(1), + Duration.ofMinutes(5), + deployment -> { + runtimeBuilds.incrementAndGet(); + return new FakeRuntime(deployment.deploymentId()); + }, + declaredProvider().roles(), + capabilities, + java.time.Clock.systemUTC())) { + + assertThat(capabilities.get(RedisRole.COORDINATION)) + .containsExactlyInAnyOrder(Capability.RATE_LIMIT, Capability.IDEMPOTENCY); + assertThat(registry.boundRoles()).containsExactly(RedisRole.COORDINATION); + assertThat(runtimeBuilds).hasValue(1); + } + } + + private static Set selectedRoles(Map> capabilities) { + return capabilities.entrySet().stream() + .filter(entry -> !entry.getValue().isEmpty()) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + private static RedisProviderSettings declaredProvider() { + return new RedisProviderSettings( + Map.of( + "cache-main", standalone("cache-main"), + "coord-main", standalone("coord-main"), + "session-main", standalone("session-main")), + Map.of( + RedisRole.CACHE, new RedisRoleBinding("cache-main", false, "allkeys-lfu"), + RedisRole.COORDINATION, new RedisRoleBinding("coord-main", true, "noeviction"), + RedisRole.SESSION, new RedisRoleBinding("session-main", true, "noeviction"))); + } + + private static RedisProviderSettings.DeploymentProperties standalone(String deploymentId) { + return new RedisProviderSettings.DeploymentProperties( + RedisProviderSettings.Topology.STANDALONE, + new RedisProviderSettings.StandaloneProperties( + List.of( + new RedisProviderSettings.EndpointProperties(deploymentId + ".internal", 6379))), + null, + null, + 0, + new RedisProviderSettings.AuthenticationProperties( + "runtime", "secret://environment/REDIS_PASSWORD"), + new RedisProviderSettings.TlsProperties( + true, true, "secret://environment/REDIS_TRUST_PEM")); + } + + private static final class FakeRuntime implements RedisRoutableCommandRuntime { + + private final String deploymentId; + private final Map values = new java.util.HashMap<>(); + + private FakeRuntime(String deploymentId) { + this.deploymentId = deploymentId; + } + + @Override + public void probe(Duration timeout) {} + + @Override + public String deploymentId() { + return deploymentId; + } + + @Override + public byte[] get(RedisPhysicalKey key) { + byte[] value = values.get(key); + return value == null ? null : value.clone(); + } + + @Override + public void set(RedisPhysicalKey key, RedisBinaryValue value, Duration timeToLive) { + values.put(key, value.copyEncoded()); + } + + @Override + public long delete(RedisPhysicalKey key) { + return values.remove(key) == null ? 0 : 1; + } + + @Override + public RedisCatalogProgramReply executeCatalogProgram( + RedisCatalogProgramInvocation invocation) { + RedisProgramId programId = invocation.programIdOrNull(); + if (programId == null) { + return RedisCatalogProgramReply.value( + "ACL_OK".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + return switch (programId) { + case RATE_FIXED_WINDOW_V2 -> + RedisCatalogProgramReply.multi( + ascii("STATE_INCOMPATIBLE", "NONE", "1", "1", "1", "0", "0", "0")); + case IDEMPOTENCY_CLAIM_V1 -> + RedisCatalogProgramReply.multi(ascii("STATE_INCOMPATIBLE", "0", "0", "-", "-", "-")); + default -> throw new AssertionError("unexpected semantic program " + programId); + }; + } + + @Override + public String loadCatalogProgram(RedisCatalogProgramInvocation invocation) { + return invocation.sha1(); + } + + @Override + public void close() {} + + private static List ascii(String... fields) { + return java.util.Arrays.stream(fields) + .map(field -> field.getBytes(java.nio.charset.StandardCharsets.US_ASCII)) + .toList(); + } + } +} diff --git a/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheColdStartCompositionTest.java b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheColdStartCompositionTest.java new file mode 100644 index 0000000..49683da --- /dev/null +++ b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisOptionalCacheColdStartCompositionTest.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.security.DestroyableRedisSecret; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.bootstrap.runtime.redis.RedisHealthContributorConfig; +import java.time.Instant; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class RedisOptionalCacheColdStartCompositionTest { + + @Test + void selectedOptionalCacheStartsItsRuntimeCacheBeanAndDegradedHealthOnTypedTransientOutage() { + new ApplicationContextRunner() + .withUserConfiguration( + RedisCanonicalConfig.class, + RedisCanonicalCacheConfig.class, + RedisHealthContributorConfig.class) + .withBean( + RedisRuntimeConnector.class, + () -> + deployment -> { + throw new RedisTemporaryConnectionException(); + }) + .withBean( + RedisCredentialMaterialProvider.class, + RedisOptionalCacheColdStartCompositionTest::secretProvider) + .withPropertyValues(optionalCacheProperties()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("redisCanonicalRoleRegistry"); + assertThat(context).hasBean("redisCanonicalDefaultCacheRegion"); + assertThat(context).hasBean("redisOptional"); + assertThat(context).doesNotHaveBean("redisRequired"); + assertThat( + context.getBean("redisOptional", HealthIndicator.class).health().toString()) + .contains("DEGRADED", "COMMAND_UNAVAILABLE") + .doesNotContain("cache.internal"); + }); + } + + @Test + void requiredTransientAndOptionalPermanentConnectorFailuresStillFailTheContext() { + new ApplicationContextRunner() + .withUserConfiguration(RedisCanonicalConfig.class, RedisHealthContributorConfig.class) + .withBean( + RedisRuntimeConnector.class, + () -> + deployment -> { + throw new RedisTemporaryConnectionException(); + }) + .withPropertyValues(requiredCoordinationProperties()) + .run(context -> assertThat(context).hasFailed()); + + new ApplicationContextRunner() + .withUserConfiguration(RedisCanonicalConfig.class, RedisHealthContributorConfig.class) + .withBean( + RedisRuntimeConnector.class, + () -> + deployment -> { + throw new IllegalStateException("permanent authentication failure"); + }) + .withPropertyValues(optionalCacheProperties()) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void duplicateRuntimeConnectorSeamsFailClosedInsteadOfChoosingSilently() { + RedisRuntimeConnector first = + deployment -> { + throw new RedisTemporaryConnectionException(); + }; + RedisRuntimeConnector second = + deployment -> { + throw new RedisTemporaryConnectionException(); + }; + + new ApplicationContextRunner() + .withUserConfiguration(RedisCanonicalConfig.class) + .withBean("firstRedisRuntimeConnector", RedisRuntimeConnector.class, () -> first) + .withBean("secondRedisRuntimeConnector", RedisRuntimeConnector.class, () -> second) + .withPropertyValues(optionalCacheProperties()) + .run(context -> assertThat(context).hasFailed()); + } + + private static RedisCredentialMaterialProvider secretProvider() { + byte[] raw = new byte[32]; + java.util.Arrays.fill(raw, (byte) 7); + char[] encoded = Base64.getEncoder().encodeToString(raw).toCharArray(); + java.util.Arrays.fill(raw, (byte) 0); + return reference -> + new VersionedRedisCredentialMaterial( + "composition-v1", + Instant.parse("2030-01-01T00:00:00Z"), + DestroyableRedisSecret.from(encoded)); + } + + private static String[] optionalCacheProperties() { + return new String[] { + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference=secret://environment/CACHE_KEY_HMAC", + "ca-skeleton.providers.redis.deployments.cache-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].host=cache.internal", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.username=cache-runtime", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.password-reference=secret://environment/CACHE_PASSWORD", + "ca-skeleton.providers.redis.deployments.cache-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.trust-bundle-reference=secret://environment/CACHE_TRUST", + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main", + "ca-skeleton.providers.redis.roles.cache.required=false", + "ca-skeleton.providers.redis.roles.cache.expected-eviction=allkeys-lfu" + }; + } + + private static String[] requiredCoordinationProperties() { + return new String[] { + "ca-skeleton.capabilities.rate-limit.provider=redis", + "ca-skeleton.providers.redis.deployments.coord-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.coord-main.standalone.endpoints[0].host=coord.internal", + "ca-skeleton.providers.redis.deployments.coord-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.coord-main.authentication.username=coord-runtime", + "ca-skeleton.providers.redis.deployments.coord-main.authentication.password-reference=secret://environment/COORD_PASSWORD", + "ca-skeleton.providers.redis.deployments.coord-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.coord-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.coord-main.tls.trust-bundle-reference=secret://environment/COORD_TRUST", + "ca-skeleton.providers.redis.roles.coordination.deployment-id=coord-main", + "ca-skeleton.providers.redis.roles.coordination.required=true", + "ca-skeleton.providers.redis.roles.coordination.expected-eviction=noeviction" + }; + } +} diff --git a/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCanonicalCompositionContractTest.java b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCanonicalCompositionContractTest.java new file mode 100644 index 0000000..4188322 --- /dev/null +++ b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCanonicalCompositionContractTest.java @@ -0,0 +1,232 @@ +package dev.caskeleton.bootstrap.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.auth.RedisSessionWebConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalCacheConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisEfficiencyLeaseConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisIdempotencyConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisRateLimitConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisSessionConfig; +import dev.caskeleton.adapter.outbound.cache.redis.config.RedisRole; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider; +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import dev.caskeleton.application.lease.DistributedLeasePort; +import dev.caskeleton.bootstrap.runtime.redis.RedisHealthContributorConfig; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.mock.env.MockEnvironment; + +class RedisCanonicalCompositionContractTest { + + @Test + void everyRedisSelectorActivatesOnlyItsCanonicalRole() { + Map selections = + Map.of( + "ca-skeleton.capabilities.cache.bindings.default=redis", + new ExpectedActivation(RedisRole.CACHE, Capability.CACHE), + "ca-skeleton.capabilities.rate-limit.provider=redis", + new ExpectedActivation(RedisRole.COORDINATION, Capability.RATE_LIMIT), + "ca-skeleton.capabilities.idempotency.provider=redis", + new ExpectedActivation(RedisRole.COORDINATION, Capability.IDEMPOTENCY), + "ca-skeleton.capabilities.lease.provider=redis", + new ExpectedActivation(RedisRole.COORDINATION, Capability.EFFICIENCY_LEASE), + "ca-skeleton.security.auth-mode=redis-session", + new ExpectedActivation(RedisRole.SESSION, Capability.SESSION)); + + selections.forEach( + (selector, expected) -> { + MockEnvironment environment = declaredRoleEnvironment(); + String[] selectorParts = selector.split("=", 2); + environment.setProperty(selectorParts[0], selectorParts[1]); + + Map> active = + RedisCanonicalConfig.selectedCapabilities(environment); + + assertThat(active.get(expected.role())).containsExactly(expected.capability()); + active.forEach( + (role, capabilities) -> { + if (role != expected.role()) { + assertThat(capabilities).isEmpty(); + } + }); + }); + } + + @Test + void declaredRolesRemainFullyInertUntilACapabilitySelectsRedis() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + + new ApplicationContextRunner() + .withUserConfiguration( + RedisCanonicalConfig.class, + RedisCanonicalCacheConfig.class, + RedisCacheAdapterConfig.class, + RedisEfficiencyLeaseConfig.class, + RedisIdempotencyConfig.class, + RedisRateLimitConfig.class, + RedisSessionConfig.class, + RedisSessionWebConfig.class, + RedisHealthContributorConfig.class) + .withBean( + RedisCredentialMaterialProvider.class, + () -> + reference -> { + credentialResolutions.incrementAndGet(); + throw new AssertionError("unselected Redis role resolved credential material"); + }) + .withBean( + RedisTrustMaterialProvider.class, + () -> + reference -> { + trustResolutions.incrementAndGet(); + throw new AssertionError("unselected Redis role resolved trust material"); + }) + .withPropertyValues(disabledCapabilityProperties()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(RedisHealthSnapshotProvider.class).snapshot().roles()) + .isEmpty(); + assertThat(context.getBeansOfType(DistributedLeasePort.class)).isEmpty(); + assertThat(context.getBeansOfType(IdempotencyStorePortV2.class)).isEmpty(); + assertThat(context.getBeansOfType(EdgeRateLimitPort.class)).isEmpty(); + assertThat(context) + .doesNotHaveBean("redisCanonicalDefaultCacheRegion") + .doesNotHaveBean("redisCanonicalDefaultCacheInvalidationSubscription") + .doesNotHaveBean("redisLuaVersionedSessionStore") + .doesNotHaveBean("redisVersionedSessionRepository") + .doesNotHaveBean("springSessionRepositoryFilter") + .doesNotHaveBean("redisRequired") + .doesNotHaveBean("redisOptional"); + assertThat(context.getBeanFactory().getBeanDefinitionNames()) + .allSatisfy( + beanName -> { + Class beanType = context.getBeanFactory().getType(beanName, false); + assertThat(beanType == null ? "" : beanType.getName()) + .doesNotStartWith("io.lettuce."); + }); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + @Test + void unboundProviderDefinitionsResolveNoMaterialAndOpenNoNativeClient() { + AtomicInteger credentialResolutions = new AtomicInteger(); + AtomicInteger trustResolutions = new AtomicInteger(); + RedisCredentialMaterialProvider credentialProvider = + reference -> { + credentialResolutions.incrementAndGet(); + throw new AssertionError("unbound Redis deployment resolved credential material"); + }; + RedisTrustMaterialProvider trustProvider = + reference -> { + trustResolutions.incrementAndGet(); + throw new AssertionError("unbound Redis deployment resolved trust material"); + }; + + new ApplicationContextRunner() + .withUserConfiguration( + RedisCanonicalConfig.class, + RedisEfficiencyLeaseConfig.class, + RedisIdempotencyConfig.class, + RedisRateLimitConfig.class) + .withBean(RedisCredentialMaterialProvider.class, () -> credentialProvider) + .withBean(RedisTrustMaterialProvider.class, () -> trustProvider) + .withPropertyValues( + "ca-skeleton.providers.redis.deployments.unused.topology=standalone", + "ca-skeleton.providers.redis.deployments.unused.standalone.endpoints[0].host=unused.invalid", + "ca-skeleton.providers.redis.deployments.unused.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.unused.database=0", + "ca-skeleton.providers.redis.deployments.unused.authentication.username=unused-runtime", + "ca-skeleton.providers.redis.deployments.unused.authentication.password-reference=secret://environment/APP_CACHE_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.unused.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.unused.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.unused.tls.trust-bundle-reference=secret://environment/APP_CACHE_REDIS_TRUST_PEM") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("redisCanonicalRoleRegistry"); + assertThat(context.getBeansOfType(DistributedLeasePort.class)).isEmpty(); + assertThat(context.getBeansOfType(IdempotencyStorePortV2.class)).isEmpty(); + assertThat(context.getBeansOfType(EdgeRateLimitPort.class)).isEmpty(); + assertThat(context.getBeanFactory().getBeanDefinitionNames()) + .allSatisfy( + beanName -> { + Class beanType = context.getBeanFactory().getType(beanName, false); + assertThat(beanType == null ? "" : beanType.getName()) + .doesNotStartWith("io.lettuce."); + }); + assertThat(credentialResolutions).hasValue(0); + assertThat(trustResolutions).hasValue(0); + }); + } + + private static String[] disabledCapabilityProperties() { + return new String[] { + "ca-skeleton.capabilities.cache.bindings.default=disabled", + "ca-skeleton.capabilities.rate-limit.provider=disabled", + "ca-skeleton.capabilities.idempotency.provider=jdbc", + "ca-skeleton.capabilities.lease.provider=disabled", + "ca-skeleton.security.auth-mode=jwt", + "ca-skeleton.providers.redis.deployments.cache-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].host=cache.internal", + "ca-skeleton.providers.redis.deployments.cache-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.cache-main.database=0", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.username=cache-runtime", + "ca-skeleton.providers.redis.deployments.cache-main.authentication.password-reference=secret://environment/APP_CACHE_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.cache-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.cache-main.tls.trust-bundle-reference=secret://environment/APP_CACHE_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.deployments.coord-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.coord-main.standalone.endpoints[0].host=coord.internal", + "ca-skeleton.providers.redis.deployments.coord-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.coord-main.database=0", + "ca-skeleton.providers.redis.deployments.coord-main.authentication.username=coord-runtime", + "ca-skeleton.providers.redis.deployments.coord-main.authentication.password-reference=secret://environment/APP_RATE_LIMIT_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.coord-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.coord-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.coord-main.tls.trust-bundle-reference=secret://environment/APP_RATE_LIMIT_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.deployments.session-main.topology=standalone", + "ca-skeleton.providers.redis.deployments.session-main.standalone.endpoints[0].host=session.internal", + "ca-skeleton.providers.redis.deployments.session-main.standalone.endpoints[0].port=6379", + "ca-skeleton.providers.redis.deployments.session-main.database=0", + "ca-skeleton.providers.redis.deployments.session-main.authentication.username=session-runtime", + "ca-skeleton.providers.redis.deployments.session-main.authentication.password-reference=secret://environment/APP_SESSION_REDIS_PASSWORD", + "ca-skeleton.providers.redis.deployments.session-main.tls.enabled=true", + "ca-skeleton.providers.redis.deployments.session-main.tls.verify-hostname=true", + "ca-skeleton.providers.redis.deployments.session-main.tls.trust-bundle-reference=secret://environment/APP_SESSION_REDIS_TRUST_PEM", + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main", + "ca-skeleton.providers.redis.roles.cache.required=false", + "ca-skeleton.providers.redis.roles.cache.expected-eviction=allkeys-lfu", + "ca-skeleton.providers.redis.roles.coordination.deployment-id=coord-main", + "ca-skeleton.providers.redis.roles.coordination.required=true", + "ca-skeleton.providers.redis.roles.coordination.expected-eviction=noeviction", + "ca-skeleton.providers.redis.roles.session.deployment-id=session-main", + "ca-skeleton.providers.redis.roles.session.required=true", + "ca-skeleton.providers.redis.roles.session.expected-eviction=noeviction" + }; + } + + private static MockEnvironment declaredRoleEnvironment() { + MockEnvironment environment = new MockEnvironment(); + for (String property : disabledCapabilityProperties()) { + String[] parts = property.split("=", 2); + environment.setProperty(parts[0], parts[1]); + } + return environment; + } + + private record ExpectedActivation(RedisRole role, Capability capability) {} +} diff --git a/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCiAggregatorContractTest.java b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCiAggregatorContractTest.java new file mode 100644 index 0000000..49efb7b --- /dev/null +++ b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisCiAggregatorContractTest.java @@ -0,0 +1,140 @@ +package dev.caskeleton.bootstrap.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class RedisCiAggregatorContractTest { + + private static final Set BLOCKING_JOBS = + Set.of("quality-gates", "sample-off", "gate-matrix-lint", "redis-standalone"); + + @Test + void releaseAggregatorNeedsAndChecksEveryBlockingJob() throws IOException { + String workflow = + Files.readString(repositoryRoot().resolve(".github/workflows/ci-quality-gates.yml")); + String releaseGate = jobBody(workflow, "release-gate"); + + assertThat(needs(releaseGate)).containsExactlyInAnyOrderElementsOf(BLOCKING_JOBS); + assertThat(releaseGate) + .contains("QUALITY_RESULT: ${{ needs.quality-gates.result }}") + .contains("SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}") + .contains("MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}") + .contains("REDIS_RESULT: ${{ needs.redis-standalone.result }}") + .contains("\"${REDIS_RESULT}\""); + } + + @Test + void readinessWorkflowUsesStrictGradleMatrixAndReconcilesSanitizedEvidence() throws IOException { + String workflow = + Files.readString( + repositoryRoot().resolve(".github/workflows/redis-production-readiness.yml")); + String resolver = jobBody(workflow, "resolve-redis-readiness"); + + assertThat(resolver) + .contains("./gradlew writeRedisCiMatrix") + .contains("redis-readiness-matrix.json") + .doesNotContain("registry.read_text"); + + for (String jobId : + Set.of( + "redis-security", + "redis-sentinel", + "redis-cluster", + "redis-fault", + "redis-compatibility", + "selected-card-readiness", + "redis-all-candidates")) { + assertThat(jobBody(workflow, jobId)) + .as("sanitized evidence upload for %s", jobId) + .contains("id: redis-tests") + .contains("id: redis-evidence-sanitizer") + .contains("if: always()") + .contains("steps.redis-evidence-sanitizer.outcome == 'success'") + .contains("build/redis-evidence") + .contains("if-no-files-found: error") + .doesNotContain("build/test-results") + .doesNotContain("build/reports/tests") + .doesNotContain("container logs"); + } + + assertThat(jobBody(workflow, "selected-card-readiness")) + .contains("name: redis-selected-${{ matrix.cardId }}"); + for (String jobId : + Set.of( + "resolve-redis-readiness", + "redis-security", + "redis-sentinel", + "redis-cluster", + "redis-fault", + "redis-compatibility", + "redis-all-candidates", + "redis-production-readiness")) { + assertThat(jobBody(workflow, jobId)) + .as("selected artifact name ownership for %s", jobId) + .doesNotContain("name: redis-selected-"); + } + String readiness = jobBody(workflow, "redis-production-readiness"); + assertThat(readiness) + .contains("if: ${{ always() && needs.resolve-redis-readiness.result == 'success' }}") + .contains("Require the exact selected matrix result") + .contains("selected-card-readiness result mismatch") + .contains("Record the downloaded selected artifact inventory") + .contains("downloaded selected artifact inventory mismatch") + .contains("redis-ci-result.json") + .contains("actions/download-artifact") + .contains("name: redis-readiness-control") + .contains("pattern: redis-selected-*") + .contains("verifyRedisSelectedEvidenceArtifacts") + .contains("redisProductionReadiness") + .contains("-PredisCiResultFile=") + .contains("needs.resolve-redis-readiness.outputs.selected_count == '0'") + .contains("expected_result = \"skipped\" if selected_count == 0 else \"success\"") + .contains("needs.resolve-redis-readiness.outputs.selected_count != '0'") + .contains("needs.selected-card-readiness.result == 'success'"); + } + + private static Set needs(String job) { + Set result = new LinkedHashSet<>(); + boolean inNeeds = false; + for (String line : job.lines().toList()) { + if (line.equals(" needs:")) { + inNeeds = true; + continue; + } + if (inNeeds && line.matches(" - [a-z0-9-]+")) { + result.add(line.substring(line.indexOf('-') + 1).trim()); + } else if (inNeeds && !line.isBlank()) { + break; + } + } + return result; + } + + private static String jobBody(String workflow, String jobId) { + Pattern pattern = + Pattern.compile( + "(?ms)^ " + Pattern.quote(jobId) + ":\\n(.*?)(?=^ [a-zA-Z0-9_-]+:\\n|\\z)"); + Matcher matcher = pattern.matcher(workflow); + assertThat(matcher.find()).as("workflow job %s", jobId).isTrue(); + return " " + jobId + ":\n" + matcher.group(1); + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath().normalize(); + while (current != null && !Files.isDirectory(current.resolve(".github/workflows"))) { + current = current.getParent(); + } + if (current == null) { + throw new IllegalStateException("repository root containing .github/workflows was not found"); + } + return current; + } +} diff --git a/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisDefaultActivationContractTest.java b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisDefaultActivationContractTest.java new file mode 100644 index 0000000..32aa776 --- /dev/null +++ b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisDefaultActivationContractTest.java @@ -0,0 +1,139 @@ +package dev.caskeleton.bootstrap.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.auth.RedisSessionWebConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalCacheConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisEfficiencyLeaseConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisIdempotencyConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisRateLimitConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisSessionConfig; +import dev.caskeleton.bootstrap.runtime.redis.RedisHealthContributorConfig; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.FileSystemResource; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +class RedisDefaultActivationContractTest { + + @Test + void shippedLocalEnvironmentDoesNotEnableTransportWithoutAProvider() throws IOException { + Path root = repositoryRoot(); + Map environment = + Files.readAllLines(root.resolve("src/.env")).stream() + .filter(line -> line.matches("[A-Z][A-Z0-9_]*=.*")) + .map(line -> line.split("=", 2)) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + + assertThat(environment) + .containsEntry("APP_RATE_LIMIT_ENABLED", "false") + .containsEntry("APP_RATE_LIMIT_PROVIDER", "disabled"); + } + + @Test + void shippedConfigurationParsesUniquelyAndBootsWithoutRedisActivation() throws IOException { + Path root = repositoryRoot(); + Map environment = shippedEnvironment(root); + Map environmentProperties = new LinkedHashMap<>(environment); + Path applicationYaml = root.resolve("src/app-bootstrap/src/main/resources/application.yml"); + Map application = parsedYaml(applicationYaml); + List> configuration = + new YamlPropertySourceLoader() + .load("shipped-application", new FileSystemResource(applicationYaml)); + + Map caSkeleton = child(application, "ca-skeleton"); + Map capabilities = child(caSkeleton, "capabilities"); + Map rateLimit = child(capabilities, "rate-limit"); + assertThat(caSkeleton).doesNotContainKey("rate-limit"); + assertThat(rateLimit) + .containsEntry("provider", "${APP_RATE_LIMIT_PROVIDER:disabled}") + .doesNotContainKey("algorithm"); + + new ApplicationContextRunner() + .withInitializer( + context -> { + for (PropertySource source : configuration) { + context.getEnvironment().getPropertySources().addLast(source); + } + context + .getEnvironment() + .getPropertySources() + .addFirst(new MapPropertySource("shipped-env", environmentProperties)); + }) + .withUserConfiguration( + RedisCanonicalConfig.class, + RedisCanonicalCacheConfig.class, + RedisCacheAdapterConfig.class, + RedisEfficiencyLeaseConfig.class, + RedisIdempotencyConfig.class, + RedisRateLimitConfig.class, + RedisSessionConfig.class, + RedisSessionWebConfig.class, + RedisHealthContributorConfig.class) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(RedisHealthSnapshotProvider.class).snapshot().roles()) + .isEmpty(); + assertThat(context) + .doesNotHaveBean("redisCanonicalDefaultCacheRegion") + .doesNotHaveBean("redisCanonicalDefaultCacheInvalidationSubscription") + .doesNotHaveBean("distributedRateLimiter") + .doesNotHaveBean("redisIdempotencyStoreV2") + .doesNotHaveBean("distributedLeasePort") + .doesNotHaveBean("redisLuaVersionedSessionStore") + .doesNotHaveBean("redisVersionedSessionRepository") + .doesNotHaveBean("springSessionRepositoryFilter") + .doesNotHaveBean("redisRequired") + .doesNotHaveBean("redisOptional"); + }); + } + + private static Map shippedEnvironment(Path root) throws IOException { + return Files.readAllLines(root.resolve("src/.env")).stream() + .filter(line -> line.matches("[A-Z][A-Z0-9_]*=.*")) + .map(line -> line.split("=", 2)) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + } + + @SuppressWarnings("unchecked") + private static Map parsedYaml(Path path) throws IOException { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + try (var reader = Files.newBufferedReader(path)) { + return (Map) new Yaml(new SafeConstructor(options)).load(reader); + } + } + + @SuppressWarnings("unchecked") + private static Map child(Map parent, String key) { + assertThat(parent).containsKey(key); + return (Map) parent.get(key); + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath().normalize(); + while (current != null && !Files.isDirectory(current.resolve(".github/workflows"))) { + current = current.getParent(); + } + if (current == null) { + throw new IllegalStateException("repository root containing .github/workflows was not found"); + } + return current; + } +} diff --git a/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisIdempotencyProviderSelectionContractTest.java b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisIdempotencyProviderSelectionContractTest.java new file mode 100644 index 0000000..0f0c31b --- /dev/null +++ b/src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/bootstrap/redis/RedisIdempotencyProviderSelectionContractTest.java @@ -0,0 +1,203 @@ +package dev.caskeleton.bootstrap.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyReaper; +import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter; +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimOutcome; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyCompleteOutcome; +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.IdempotencyExecutorV2; +import dev.caskeleton.application.idempotency.IdempotencyFailOutcome; +import dev.caskeleton.application.idempotency.IdempotencyFailureDisposition; +import dev.caskeleton.application.idempotency.IdempotencyInspection; +import dev.caskeleton.application.idempotency.IdempotencyInspectionRequest; +import dev.caskeleton.application.idempotency.IdempotencyOwner; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyReleaseOutcome; +import dev.caskeleton.application.idempotency.IdempotencyRenewOutcome; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStartOutcome; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import dev.caskeleton.bootstrap.idempotency.IdempotencyProviderSelectionConfig; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class RedisIdempotencyProviderSelectionContractTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(IdempotencyProviderSelectionConfig.class); + + @Test + void jdbcAndRedisModesEachRequireExactlyTheirOwnVersionedPair() { + IdempotencyStorePort jdbcStore = new NoOpJdbcStore(); + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=jdbc") + .withBean(IdempotencyStorePort.class, () -> jdbcStore) + .withBean( + IdempotencyExecutor.class, + () -> new IdempotencyExecutor(jdbcStore, Clock.systemUTC(), Duration.ofHours(1))) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(IdempotencyStorePort.class); + assertThat(context.getBeansOfType(IdempotencyStorePortV2.class)).isEmpty(); + }); + + IdempotencyStorePortV2 redisStore = new NoOpRedisStore(); + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=redis") + .withBean(IdempotencyStorePortV2.class, () -> redisStore) + .withBean( + IdempotencyExecutorV2.class, + () -> + new IdempotencyExecutorV2( + redisStore, + Duration.ofSeconds(30), + Duration.ofHours(1), + Duration.ofHours(1), + "json-v2", + "policy-v2")) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(IdempotencyStorePortV2.class); + assertThat(context.getBeansOfType(IdempotencyStorePort.class)).isEmpty(); + }); + } + + @Test + void duplicateCrossVersionProvidersAndUnknownSelectorFailFast() { + IdempotencyStorePort jdbcStore = new NoOpJdbcStore(); + IdempotencyStorePortV2 redisStore = new NoOpRedisStore(); + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=redis") + .withBean(IdempotencyStorePort.class, () -> jdbcStore) + .withBean( + IdempotencyExecutor.class, + () -> new IdempotencyExecutor(jdbcStore, Clock.systemUTC(), Duration.ofHours(1))) + .withBean(IdempotencyStorePortV2.class, () -> redisStore) + .withBean( + IdempotencyExecutorV2.class, + () -> + new IdempotencyExecutorV2( + redisStore, + Duration.ofSeconds(30), + Duration.ofHours(1), + Duration.ofHours(1), + "json-v2", + "policy-v2")) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ambiguous"); + }); + + runner + .withPropertyValues("ca-skeleton.capabilities.idempotency.provider=jdbc-and-redis") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure().getMessage()) + .contains("ca-skeleton.capabilities.idempotency"); + }); + } + + @Test + void jpaV1StoreAndReaperAreConditionedOnTheExactJdbcMode() { + assertJdbcCondition(IdempotencyStoreAdapter.class); + assertJdbcCondition(IdempotencyReaper.class); + } + + private static void assertJdbcCondition(Class type) { + ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class); + assertThat(condition).isNotNull(); + assertThat(condition.name()).containsExactly("ca-skeleton.capabilities.idempotency.provider"); + assertThat(condition.havingValue()).isEqualTo("jdbc"); + assertThat(condition.matchIfMissing()).isTrue(); + } + + private static final class NoOpJdbcStore implements IdempotencyStorePort { + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + return false; + } + + @Override + public Optional find(IdempotencyScope scope, Instant now) { + return Optional.empty(); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) {} + + @Override + public void discard(IdempotencyScope scope) {} + } + + private static final class NoOpRedisStore implements IdempotencyStorePortV2 { + + @Override + public IdempotencyClaimAttempt newClaimAttempt(String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyStartOutcome markExecutionStarted( + IdempotencyOwner owner, String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyRenewOutcome renew( + IdempotencyOwner owner, Duration processingLeaseTtl, String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyReleaseOutcome releaseBeforeExecution( + IdempotencyOwner owner, String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyInspection inspect(IdempotencyInspectionRequest request) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java index e83c29b..99ce158 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java @@ -10,6 +10,13 @@ import dev.caskeleton.adapter.outbound.cache.core.CacheStore; import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter; import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig; import dev.caskeleton.adapter.outbound.cache.redis.RedisClient; +import dev.caskeleton.adapter.outbound.cache.redis.RedisRateLimitConfig; +import dev.caskeleton.adapter.outbound.fileserver.FileExportConfig; +import dev.caskeleton.adapter.outbound.fileserver.FileserverR2Config; +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClient; +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard; +import dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; import dev.caskeleton.adapter.outbound.messaging.MessagingConfig; import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher; import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; @@ -28,12 +35,15 @@ import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNoti import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackClient; import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig; import dev.caskeleton.adapter.outbound.support.OutboundSupportConfig; +import dev.caskeleton.application.filepublication.FilePublicationPort; import dev.caskeleton.application.notification.Channel; import dev.caskeleton.application.notification.Notification; import dev.caskeleton.application.notification.NotificationPort; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; +import dev.caskeleton.bootstrap.httpclient.HttpClientCompositionConfig; import dev.caskeleton.shared.error.AdapterDisabledException; +import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -68,10 +78,14 @@ class OptionalAdapterBeanGatingTest { MessagingConfig.class, KafkaAdapterConfig.class, RedisCacheAdapterConfig.class, + RedisRateLimitConfig.class, CacheRouterConfig.class, NotificationConfig.class, SlackNotificationAdapterConfig.class, GoogleEmailNotificationAdapterConfig.class, + FileExportConfig.class, + FileserverR2Config.class, + HttpClientCompositionConfig.class, StubClientsConfig.class); @Test @@ -84,7 +98,15 @@ class OptionalAdapterBeanGatingTest { // real provider beans absent (Layer 1 — disabled, contributes nothing) assertThat(context.getBeansOfType(MessageBroker.class)).isEmpty(); assertThat(context.getBeansOfType(CacheStore.class)).isEmpty(); + assertThat(context.getBeansOfType(EdgeRateLimitPort.class)).isEmpty(); + assertThat(context.containsBean("distributedRateLimiter")).isFalse(); assertThat(context.getBeansOfType(NotificationProvider.class)).isEmpty(); + assertThat(context.getBeansOfType(FilePublicationPort.class)).isEmpty(); + assertThat(context.getBeansOfType(OutboundHttpClient.class)).isEmpty(); + assertThat(context.getBeansOfType(OutboundHttpShutdownGuard.class)).isEmpty(); + assertThat(context.getBeansOfType(OutboundHttpResilience.class)).isEmpty(); + assertThat(context.getBean(ResolvedHttpClientCapability.class).state()) + .isEqualTo(ResolvedHttpClientCapability.State.DISABLED_VERIFIED); // messaging: fail-fast sentinels satisfy the ports (Layer 3 fallback) assertThat(context.getBean(MessagePublisher.class)) @@ -131,12 +153,24 @@ class OptionalAdapterBeanGatingTest { }); } + @Test + void legacyRedisEnableWithoutExplicitMigrationModeCreatesNoBackend() { + runner + .withPropertyValues("app.cache.redis.enabled=true", "app.cache.redis.client-mode=external") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(CacheBackend.class)).isEmpty(); + }); + } + @Test void redisEnabledContributesTheBackendAndRoutesBoundLogicalCaches() { runner .withPropertyValues( "app.cache.redis.enabled=true", "app.cache.redis.client-mode=external", + "ca-skeleton.providers.redis.legacy-migration-enabled=true", "app.cache.bindings.worklog=redis") .run( context -> { @@ -191,6 +225,7 @@ class OptionalAdapterBeanGatingTest { .withPropertyValues( "app.cache.redis.enabled=true", "app.cache.redis.client-mode=external", + "ca-skeleton.providers.redis.legacy-migration-enabled=true", "app.cache.test-second.enabled=true", "app.cache.bindings.worklog=redis", "app.cache.bindings.session=test-second") diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java index 514af5d..e7f43fb 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java @@ -7,6 +7,7 @@ import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.EvaluationResult; import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; +import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase; import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase; import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository; import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort; @@ -59,6 +60,8 @@ class ArchitectureViolationFixtureTest { new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class); private static final JavaClasses APPLICATION_DIAGNOSTIC_FRAMEWORK_FIXTURE_ONLY = new ClassFileImporter().importClasses(ApplicationDiagnosticFrameworkViolation.class); + private static final JavaClasses ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY = + new ClassFileImporter().importClasses(RootWriteTransactionBoundaryUseCase.class); // Each WebSocket fixture is imported in ISOLATION so the two package globs in // NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..") @@ -80,6 +83,14 @@ class ArchitectureViolationFixtureTest { new ClassFileImporter().importClasses(RawLeakQueryPort.class, FakeDomainEntity.class); private static final JavaClasses GENERIC_LEAK_QUERY_PORT_ONLY = new ClassFileImporter().importClasses(GenericLeakQueryPort.class, FakeDomainEntity.class); + private static final JavaClasses B7_SETTINGS_SUFFIX_BYPASS_ONLY = + new ClassFileImporter() + .importPackages( + "dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.settingsbypass"); + private static final JavaClasses B7_ACTIVATION_PACKAGE_BYPASS_ONLY = + new ClassFileImporter() + .importPackages( + "dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.httpclient.activation"); /** Over-block guard corpus: a legitimate projection port the D1 rule must NOT flag. */ private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY = @@ -261,10 +272,23 @@ class ArchitectureViolationFixtureTest { .as( "USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch " + "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without " - + "TransactionPort.inWrite") + + "TransactionPort.inWrite or TransactionPort.inRootWrite") .isTrue(); } + @Test + void useCaseCapabilityMatchesTransactionPortBoundaryAllowsRootWriteBoundary() { + EvaluationResult result = + CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate( + ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must allow a " + + "WRITE_REPOSITORY use case that directly calls TransactionPort.inRootWrite") + .isFalse(); + } + @Test void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() { EvaluationResult result = @@ -397,6 +421,30 @@ class ArchitectureViolationFixtureTest { .isTrue(); } + @Test + void outboundAdapterMethodRuleCannotBeBypassedWithSettingsSuffix() { + EvaluationResult result = + CleanArchitectureTest.OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES.evaluate( + B7_SETTINGS_SUFFIX_BYPASS_ONLY); + + assertThat(result.hasViolation()) + .as("B7 must catch a raw adapter return even when the owner ends with Settings") + .isTrue(); + } + + @Test + void outboundAdapterMethodRuleCannotBeBypassedWithActivationPackage() { + EvaluationResult result = + CleanArchitectureTest.OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES.evaluate( + B7_ACTIVATION_PACKAGE_BYPASS_ONLY); + + assertThat(result.hasViolation()) + .as( + "B7 must catch whitelisted-name overloads returning raw adapter types inside an " + + "activation-named package") + .isTrue(); + } + @Test void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() { EvaluationResult result = 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 f259fa9..6211660 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 @@ -387,7 +387,7 @@ class CleanArchitectureTest { "feature-domain-feature-onboarding-contract D4: a use case that declares a " + "repository-backed transaction capability must call the matching " + "TransactionPort boundary directly: READ_REPOSITORY+READ_ONLY -> inRead, " - + "WRITE_REPOSITORY+WRITE -> inWrite, REQUIRES_NEW -> inNew. " + + "WRITE_REPOSITORY+WRITE -> inWrite or inRootWrite, REQUIRES_NEW -> inNew. " + "RepositoryAccess.NONE may intentionally skip a DB transaction. " + "UNSUPPORTED_IMPL_DECISION: static analysis reaches direct calls only; a " + "transaction hidden behind a helper remains a code-review concern.") @@ -583,24 +583,24 @@ class CleanArchitectureTest { String transactionMode = enumAnnotationValue(annotation, "transactionMode"); String repositoryAccess = enumAnnotationValue(annotation, "repositoryAccess"); - String requiredMethod = null; + Set requiredMethods = Set.of(); if ("REQUIRES_NEW".equals(transactionMode)) { - requiredMethod = "inNew"; + requiredMethods = Set.of("inNew"); } else if ("WRITE".equals(transactionMode) && "WRITE_REPOSITORY".equals(repositoryAccess)) { - requiredMethod = "inWrite"; + requiredMethods = Set.of("inWrite", "inRootWrite"); } else if ("READ_ONLY".equals(transactionMode) && "READ_REPOSITORY".equals(repositoryAccess)) { - requiredMethod = "inRead"; + requiredMethods = Set.of("inRead"); } - if (requiredMethod == null) { + if (requiredMethods.isEmpty()) { return; } for (JavaMethodCall call : item.getMethodCallsFromSelf()) { if ("dev.caskeleton.application.transaction.TransactionPort" .equals(call.getTargetOwner().getFullName()) - && requiredMethod.equals(call.getName())) { + && requiredMethods.contains(call.getName())) { return; } } @@ -614,8 +614,8 @@ class CleanArchitectureTest { + transactionMode + ", repositoryAccess = " + repositoryAccess - + ") but does not directly call TransactionPort." - + requiredMethod + + ") but does not directly call one of TransactionPort." + + requiredMethods + "(...)")); } }; @@ -925,9 +925,9 @@ class CleanArchitectureTest { JavaClass.Predicates.resideOutsideOfPackage( "..adapter.outbound.identifier..")))) .as( - "adapter:outbound:identifier is a non-IO driven adapter (UUIDv7 generation/codec) — it " - + "must not reach into sibling adapters, persistence, or the composition " - + "root (feature-resource-identifier-contract §4 taxonomy).") + "adapter:outbound:identifier is a non-IO driven adapter (UUIDv7 generation/codec) —" + + " it must not reach into sibling adapters, persistence, or the composition root" + + " (feature-resource-identifier-contract §4 taxonomy).") .allowEmptyShould(true); // ---- Task 7: explicit inbound/outbound adapter topology (hexagonal driving/driven split) ---- @@ -1051,9 +1051,9 @@ class CleanArchitectureTest { .dependOnClassesThat() .resideInAPackage("..fixtures..") .as( - "feature-test-taxonomy-fixture-contract D6: production code must never depend on a " - + "test fixture — fixtures live in src/test/.../fixtures/ (test-only); this guards " - + "against a fixture leaking onto the main classpath.") + "feature-test-taxonomy-fixture-contract D6: production code must never depend on a" + + " test fixture — fixtures live in src/test/.../fixtures/ (test-only); this" + + " guards against a fixture leaking onto the main classpath.") .allowEmptyShould(true); // ---- feature-boundary-validation-mapping-contract ---- @@ -1252,6 +1252,20 @@ class CleanArchitectureTest { .areDeclaredInClassesThat() .areNotAnnotatedWith( "org.springframework.boot.context.properties.ConfigurationProperties") + // The canonical HTTP control plane and explicit legacy factories have a small exact + // method whitelist below. Names, packages, and arbitrary @Bean annotations do not bypass + // B7. + .and(notAnExactHttpControlPlaneMethod()) + // Redis provider binding and secret material provider SPIs likewise expose only a small + // exact set of typed composition accessors. Command/router/native runtime and secret + // value accessors are deliberately not exempt. + .and(notAnExactRedisCompositionMethod()) + .and() + // Package-private implementation types cannot expose their methods outside the + // adapter package. B7 protects the externally reachable adapter API, not internal + // records and fault-injection seams used to implement that API. + .areDeclaredInClassesThat() + .arePublic() .and() .arePublic() .and() @@ -1263,15 +1277,226 @@ class CleanArchitectureTest { "..adapter.inbound.web..", "..adapter.outbound.persistence..")) .as( - "B7: outbound adapter public methods must return domain types (or " - + "primitives/wrappers/Optional) — raw external response types must not " - + "escape the adapter package " - + "(feature-boundary-validation-mapping-contract B7 ACL). @Configuration " - + "@Bean factory methods and @ConfigurationProperties settings holders are " - + "excluded — they assemble port bindings / bind config, not adapter " - + "response surfaces.") + "B7: externally reachable outbound adapter public methods must return domain types" + + " (or primitives/wrappers/Optional) — raw external response types must not" + + " escape the adapter package (feature-boundary-validation-mapping-contract B7" + + " ACL). @Configuration @Bean factory methods and @ConfigurationProperties" + + " settings holders are excluded — they assemble port bindings / bind config," + + " not adapter response surfaces.") .allowEmptyShould(true); + private static final Set B7_EXACT_HTTP_CONTROL_PLANE_METHODS = + Set.of( + signature( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings", + "retry", + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings$Retry"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings", + "circuitBreaker", + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings$CircuitBreaker"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClientConfig", + "outboundHttpShutdownGuard", + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClientConfig", + "outboundHttpErrorMapper", + "dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClientConfig", + "outboundHttpDependencyLogger", + "dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClientConfig", + "outboundRetryPolicy", + List.of( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings", + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard", + "dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper"), + "dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilienceConfig", + "outboundHttpResilience", + List.of( + "dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings", + "dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy", + "org.springframework.beans.factory.ObjectProvider"), + "dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientActivationResolver", + "resolve", + List.of( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration", + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpOperationCatalogRegistry", + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientReadinessCardRegistry"), + "dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration", + "expectedState", + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientExpectedState"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration$DestinationDefinition", + "operationCatalogId", + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration$OperationCatalogId"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration$DestinationDefinition", + "profile", + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration$DestinationProfile"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfigurationBinder", + "bind", + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientReadinessCardRegistry", + "require", + List.of("java.lang.String"), + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientReadinessCardRegistry$Maturity"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpOperationCatalogRegistry", + "require", + List.of( + "dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration$OperationCatalogId"), + "dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationCatalog"), + signature( + "dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability", + "state", + "dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability$State")); + + private static DescribedPredicate notAnExactHttpControlPlaneMethod() { + return new DescribedPredicate<>("not an exact HTTP control-plane accessor/factory") { + @Override + public boolean test(JavaMethod method) { + return !B7_EXACT_HTTP_CONTROL_PLANE_METHODS.contains(ExactMethodSignature.from(method)); + } + }; + } + + private static final Set B7_EXACT_REDIS_COMPOSITION_METHODS = + Set.of( + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings", + "dataAuthentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Authentication"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings", + "dataTls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Tls"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Standalone", + "dataAuthentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Authentication"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Standalone", + "dataTls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Tls"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Sentinel", + "sentinelAuthentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Authentication"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Sentinel", + "sentinelTls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Tls"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Sentinel", + "dataAuthentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Authentication"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Sentinel", + "dataTls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Tls"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Cluster", + "dataAuthentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Authentication"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Cluster", + "dataTls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisDeploymentSettings$Tls"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$DeploymentProperties", + "topology", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$Topology"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$DeploymentProperties", + "standalone", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$StandaloneProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$DeploymentProperties", + "sentinel", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$SentinelProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$DeploymentProperties", + "cluster", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$ClusterProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$DeploymentProperties", + "authentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$AuthenticationProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$DeploymentProperties", + "tls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$TlsProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$SentinelProperties", + "authentication", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$AuthenticationProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$SentinelProperties", + "tls", + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$TlsProperties"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings$RuntimeProperties", + "clientSettings", + "dev.caskeleton.adapter.outbound.cache.redis.runtime.RedisClientRuntimeSettings"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.security.RedisCredentialMaterialProvider", + "resolve", + List.of("dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference"), + "dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial"), + signature( + "dev.caskeleton.adapter.outbound.cache.redis.security.RedisTrustMaterialProvider", + "resolve", + List.of("dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference"), + "dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial")); + + private static DescribedPredicate notAnExactRedisCompositionMethod() { + return new DescribedPredicate<>("not an exact Redis composition accessor or material SPI") { + @Override + public boolean test(JavaMethod method) { + return !B7_EXACT_REDIS_COMPOSITION_METHODS.contains(ExactMethodSignature.from(method)); + } + }; + } + + private static ExactMethodSignature signature(String owner, String name, String returnType) { + return signature(owner, name, List.of(), returnType); + } + + private static ExactMethodSignature signature( + String owner, String name, List parameters, String returnType) { + return new ExactMethodSignature(owner, name, parameters, returnType); + } + + private record ExactMethodSignature( + String owner, String name, List parameters, String returnType) { + + private ExactMethodSignature { + parameters = List.copyOf(parameters); + } + + private static ExactMethodSignature from(JavaMethod method) { + List parameterTypes = + method.getRawParameterTypes().stream().map(JavaClass::getFullName).toList(); + return new ExactMethodSignature( + method.getOwner().getFullName(), + method.getName(), + parameterTypes, + method.getRawReturnType().getFullName()); + } + } + @ArchTest static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE = classes() @@ -1584,11 +1809,11 @@ class CleanArchitectureTest { .should() .haveRawType(assignableTo(ResourceId.class)) .as( - "D17 NO_LONG_ID_PK: a domain entity 'id' field must be a ResourceId value " - + "object (e.g. WorkLogId), never Long/long/int/Integer " - + "(feature-resource-identifier-contract D17). JPA @Id UUID columns in " - + "..adapter.outbound.persistence.. are out of scope — they store the UUID as the " - + "PostgreSQL native uuid type per D10.") + "D17 NO_LONG_ID_PK: a domain entity 'id' field must be a ResourceId value object" + + " (e.g. WorkLogId), never Long/long/int/Integer" + + " (feature-resource-identifier-contract D17). JPA @Id UUID columns in" + + " ..adapter.outbound.persistence.. are out of scope — they store the UUID as" + + " the PostgreSQL native uuid type per D10.") .allowEmptyShould(true); @ArchTest @@ -1759,15 +1984,15 @@ class CleanArchitectureTest { "org.springframework.web..", "org.hibernate..")) .as( - "D1 QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES: application read/query ports " - + "(classes whose simple name ends with 'QueryPort') must return " - + "application-layer projection DTOs — never a domain aggregate, JPA entity, or " - + "web type, INCLUDING through generic type arguments like List " - + "(checked via JavaType.getAllInvolvedRawTypes(), since a raw-return-type check " - + "alone misses generic leakage) " - + "(feature-application-query-bypass-contract D1 core purity guardrail). The " - + "through-aggregate read path (repository ports returning the domain aggregate) " - + "is a separate, equally-valid choice and is intentionally out of this rule's scope.") + "D1 QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES: application read/query ports" + + " (classes whose simple name ends with 'QueryPort') must return" + + " application-layer projection DTOs — never a domain aggregate, JPA entity, or" + + " web type, INCLUDING through generic type arguments like List" + + " (checked via JavaType.getAllInvolvedRawTypes(), since a raw-return-type check" + + " alone misses generic leakage) (feature-application-query-bypass-contract D1" + + " core purity guardrail). The through-aggregate read path (repository ports" + + " returning the domain aggregate) is a separate, equally-valid choice and is" + + " intentionally out of this rule's scope.") .allowEmptyShould(true); private static ArchCondition notLeakDomainJpaOrWebThroughReturnType( diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java index e7bcd96..1012ced 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java @@ -3,20 +3,26 @@ package dev.caskeleton.bootstrap.architecture; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; /** * feature-integration-adapter-templates Layer 2 (D3 / §구현 가이드 §3) — static isolation + gating guard - * for the optional integration adapters (Kafka / Redis / Slack / Google Email). Owner: this branch. + * for the optional integration adapters (Kafka / Redis / Slack / Google Email / Fileserver). Owner: + * this branch. * *

What this layer statically guarantees, and its documented limit (branch-note L111 / D3 Open * Risk): ArchUnit can prove (1) the application layer never imports an optional adapter package, - * and (2) every optional-adapter {@code @Bean} is gated by {@code @ConditionalOnProperty} — i.e. - * "the adapter candidate class HAS the {@code @ConditionalOnProperty} annotation". Whether the - * adapter is actually active at runtime is a config evaluation ArchUnit cannot reach; that - * runtime guarantee is delegated to Layer 3 ({@code AdapterDisabledException}). + * and (2) every resource-owning optional-adapter {@code @Bean} is gated by + * {@code @ConditionalOnProperty}. Redis's exact resource-free observation and zero-binding registry + * control-plane beans are exempt: their runtime test proves that an unselected provider resolves no + * material and opens no client. Whether a candidate is actually active at runtime is a + * config evaluation ArchUnit cannot reach; that runtime guarantee is delegated to Layer 3 ({@code + * AdapterDisabledException}). * *

Spring annotation types are referenced by fully-qualified NAME so this test needs no compile * dependency on spring-context / spring-boot-autoconfigure (they arrive only via adapter-outbound's @@ -29,7 +35,8 @@ class DisabledAdapterArchitectureTest { "..adapter.outbound.messaging.kafka..", "..adapter.outbound.cache.redis..", "..adapter.outbound.notification.slack..", - "..adapter.outbound.notification.email.." + "..adapter.outbound.notification.email..", + "..adapter.outbound.fileserver.." }; private static final String BEAN = "org.springframework.context.annotation.Bean"; @@ -51,7 +58,8 @@ class DisabledAdapterArchitectureTest { .resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES) .as( "D3 APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS: the application layer must " - + "not import an optional adapter package (Kafka/Redis/Slack/Google Email) — " + + "not import an optional adapter package " + + "(Kafka/Redis/Slack/Google Email/Fileserver) — " + "the static half of the disabled-adapter detection. Runtime activity is " + "delegated to Layer 3 (feature-integration-adapter-templates D3)") .allowEmptyShould(true); @@ -70,14 +78,63 @@ class DisabledAdapterArchitectureTest { .and() .areDeclaredInClassesThat() .resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES) + .and(notCanonicalRedisResourceFreeControlPlaneBeans()) .should() .beAnnotatedWith(CONDITIONAL_ON_PROPERTY) .as( - "D3 OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY: every @Bean in an " - + "optional adapter package (Kafka/Redis/Slack/Google Email) must declare " - + "@ConditionalOnProperty(app...enabled) — Layer 1 disabled-default " - + "must not be bypassable by an ungated bean. ArchUnit reaches the annotation " - + "presence only; runtime activation is Layer 3's job " - + "(feature-integration-adapter-templates D3, L111)") + "D3 OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY: every @Bean in an" + + " optional adapter package (Kafka/Redis/Slack/Google Email/Fileserver) must" + + " declare @ConditionalOnProperty(app...enabled) — Layer 1" + + " disabled-default must not be bypassable by an ungated bean. ArchUnit reaches" + + " the annotation presence only; runtime activation is Layer 3's job" + + " (feature-integration-adapter-templates D3, L111)") .allowEmptyShould(true); + + private static DescribedPredicate notCanonicalRedisResourceFreeControlPlaneBeans() { + return new DescribedPredicate<>( + "not the exact canonical Redis resource-free control-plane beans") { + @Override + public boolean test(JavaMethod method) { + return !isCanonicalRedisObservationPort(method) + && !isCanonicalRedisZeroBindingRegistry(method); + } + }; + } + + private static boolean isCanonicalRedisObservationPort(JavaMethod method) { + return hasOwnerAndSignature( + method, + "redisCapabilityObservationPort", + "dev.caskeleton.adapter.outbound.cache.redis.RedisCapabilityObservationPort", + java.util.List.of("org.springframework.beans.factory.ObjectProvider")); + } + + private static boolean isCanonicalRedisZeroBindingRegistry(JavaMethod method) { + return hasOwnerAndSignature( + method, + "redisCanonicalRoleRegistry", + "dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalRoleRegistry", + java.util.List.of( + "dev.caskeleton.adapter.outbound.cache.redis.config.RedisProviderSettings", + "org.springframework.core.env.Environment", + "org.springframework.beans.factory.ObjectProvider", + "org.springframework.beans.factory.ObjectProvider", + "org.springframework.beans.factory.ObjectProvider", + "org.springframework.beans.factory.ObjectProvider", + "dev.caskeleton.adapter.outbound.cache.redis.RedisCapabilityObservationPort")); + } + + private static boolean hasOwnerAndSignature( + JavaMethod method, String name, String returnType, java.util.List parameterTypes) { + return method + .getOwner() + .getFullName() + .equals("dev.caskeleton.adapter.outbound.cache.redis.RedisCanonicalConfig") + && method.getName().equals(name) + && method.getRawReturnType().getFullName().equals(returnType) + && method.getRawParameterTypes().stream() + .map(JavaClass::getFullName) + .toList() + .equals(parameterTypes); + } } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java new file mode 100644 index 0000000..229909f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java @@ -0,0 +1,33 @@ +package dev.caskeleton.bootstrap.architecture.fixtures.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** Positive fixture: a root-only write boundary satisfies the WRITE_REPOSITORY fitness rule. */ +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +@RequiresPermission("fixture:root-write") +public final class RootWriteTransactionBoundaryUseCase + implements CommandUseCase { + + private final TransactionPort transactionPort; + + public RootWriteTransactionBoundaryUseCase(TransactionPort transactionPort) { + this.transactionPort = transactionPort; + } + + @Override + public String handle(CommandFixture command) { + return transactionPort.inRootWrite(command::value); + } + + public record CommandFixture(String value) implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java new file mode 100644 index 0000000..d1e7fa3 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.httpclient.activation; + +import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; + +/** Proves that an activation package name cannot bypass the outbound raw-type leak guard. */ +public class EvilActivationLeak { + + public RawExternalResponseFixture require() { + return new RawExternalResponseFixture(); + } + + public RawExternalResponseFixture require(String ignored) { + return new RawExternalResponseFixture(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java new file mode 100644 index 0000000..2e4624e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java @@ -0,0 +1,11 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.settingsbypass; + +import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; + +/** Proves that a Settings suffix cannot bypass the outbound raw-type leak guard. */ +public class EvilSettings { + + public RawExternalResponseFixture leak() { + return new RawExternalResponseFixture(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java index 1cf3dc5..c04610c 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java @@ -8,7 +8,10 @@ import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.usecase.CommandUseCase; -/** Intentional write-use-case violation: declares a write but skips TransactionPort.inWrite. */ +/** + * Intentional write-use-case violation: declares a write but skips both TransactionPort.inWrite and + * TransactionPort.inRootWrite. + */ @UseCaseCapability( transactionMode = TransactionMode.WRITE, idempotency = Idempotency.NOT_IDEMPOTENT, diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java index 59ca575..e6acf21 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java @@ -5,8 +5,6 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfEmailNotificationConfigured; -import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfHttpCircuitBreakerEnabled; -import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfHttpRetryEnabled; import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfMessagingBrokerConfigured; import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfRedisCacheEnabled; import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfSlackNotificationConfigured; @@ -35,18 +33,6 @@ class OptionalAdapterConditionalExecutionContractTest { assertThat(System.getenv("APP_CACHE_REDIS_ENABLED")).isEqualTo("true"); } - @Test - @EnabledIfHttpRetryEnabled - void httpRetryAdapterRunsOnlyWhenEnabled() { - assertThat(System.getenv("APP_OUTBOUND_HTTP_RETRY_ENABLED")).isEqualTo("true"); - } - - @Test - @EnabledIfHttpCircuitBreakerEnabled - void httpCircuitBreakerAdapterRunsOnlyWhenEnabled() { - assertThat(System.getenv("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED")).isEqualTo("true"); - } - @Test @EnabledIfMessagingBrokerConfigured void messagingBrokerAdapterRunsOnlyWhenConfigured() { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java deleted file mode 100644 index d820518..0000000 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.caskeleton.bootstrap.contract.support.conditional; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; - -/** - * Gates an optional-adapter contract test to the outbound-HTTP-circuit-breaker-enabled env matrix. - * Reports DISABLED (= SKIPPED, never FAILED) when {@code APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED} - * is unset or not {@code true} (feature-contract-verification-test-suite D3). - */ -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@EnabledIfEnvironmentVariable( - named = "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED", - matches = "true", - disabledReason = - "Outbound HTTP circuit breaker disabled " - + "(APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED != true) — optional-adapter contract " - + "test runs only in the circuit-breaker-enabled env matrix") -public @interface EnabledIfHttpCircuitBreakerEnabled {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java deleted file mode 100644 index da42a1c..0000000 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.caskeleton.bootstrap.contract.support.conditional; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; - -/** - * Gates an optional-adapter contract test to the outbound-HTTP-retry-enabled env matrix. Reports - * DISABLED (= SKIPPED, never FAILED) when {@code APP_OUTBOUND_HTTP_RETRY_ENABLED} is unset or not - * {@code true} (feature-contract-verification-test-suite D3). - */ -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@EnabledIfEnvironmentVariable( - named = "APP_OUTBOUND_HTTP_RETRY_ENABLED", - matches = "true", - disabledReason = - "Outbound HTTP retry disabled (APP_OUTBOUND_HTTP_RETRY_ENABLED != true) — " - + "optional-adapter contract test runs only in the retry-enabled env matrix") -public @interface EnabledIfHttpRetryEnabled {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java new file mode 100644 index 0000000..25b6eb7 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/httpclient/HttpClientCompositionConfigTest.java @@ -0,0 +1,181 @@ +package dev.caskeleton.bootstrap.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClient; +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings; +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard; +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpClientCanonicalConfiguration; +import dev.caskeleton.adapter.outbound.httpclient.activation.HttpOperationCatalogRegistry; +import dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpDestinationId; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationCatalog; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationDescriptor; +import dev.caskeleton.adapter.outbound.httpclient.operation.HttpOperationId; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.client.RestClient; + +class HttpClientCompositionConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(HttpClientCompositionConfig.class); + + private final ApplicationContextRunner applicationYamlRunner = + new ApplicationContextRunner() + .withInitializer(HttpClientCompositionConfigTest::loadApplicationYaml) + .withUserConfiguration(HttpClientCompositionConfig.class); + + @Test + void defaultZeroBindingPublishesOnlyAnInertDisabledDescriptorAndNoRuntimeResources() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(ResolvedHttpClientCapability.class).state()) + .isEqualTo(ResolvedHttpClientCapability.State.DISABLED_VERIFIED); + assertThat(context.getBeansOfType(OutboundHttpClient.class)).isEmpty(); + assertThat(context.getBeansOfType(OutboundHttpSettings.class)).isEmpty(); + assertThat(context.getBeansOfType(RestClient.class)).isEmpty(); + assertThat(context.getBeansOfType(RestClient.Builder.class)).isEmpty(); + assertThat(context.getBeansOfType(OutboundHttpShutdownGuard.class)).isEmpty(); + assertThat(context.getBeansOfType(OutboundHttpResilience.class)).isEmpty(); + assertThat(beanNamesFor(context, "io.github.resilience4j.retry.RetryRegistry")).isEmpty(); + assertThat( + beanNamesFor( + context, "io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry")) + .isEmpty(); + assertThat(context.containsBean("outboundCallExecutor")).isFalse(); + }); + } + + @Test + void disabledWithABindingFailsStartup() { + runner + .withPropertyValues( + "ca-skeleton.capabilities.http-client.expected-state=DISABLED", + "ca-skeleton.capabilities.http-client.bindings.partner=jdk-r1") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure().getMessage()).contains("DISABLED"); + }); + } + + @Test + void activeFailsOnNotImplementedReadinessBeforeRuntimeResourcesExist() { + runner + .withBean( + HttpOperationCatalogRegistry.class, HttpClientCompositionConfigTest::activeCatalog) + .withPropertyValues( + "ca-skeleton.capabilities.http-client.expected-state=ACTIVE", + "ca-skeleton.capabilities.http-client.bindings.partner=jdk-r1", + "ca-skeleton.providers.http-client.jdk-r1.destinations.partner.operation-catalog=partner-v1", + "ca-skeleton.providers.http-client.jdk-r1.destinations.partner.profile=BUFFERED_CLASSIC") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure().getMessage()) + .contains("httpclient-static-buffered") + .contains("NOT_IMPLEMENTED"); + }); + } + + @Test + void activeCanonicalSelectionRejectsLegacySpringInputBeforeResolution() { + runner + .withPropertyValues( + "ca-skeleton.capabilities.http-client.expected-state=ACTIVE", + "ca-skeleton.capabilities.http-client.bindings.partner=jdk-r1", + "app.outbound.http.connect-timeout=2s") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure().getMessage()) + .contains("canonical") + .contains("legacy"); + }); + } + + @Test + void actualApplicationYamlKeepsLegacyInputAbsentAndZeroBindingDisabled() { + applicationYamlRunner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getEnvironment().getProperty("app.outbound.http.connect-timeout")) + .isNull(); + assertThat(context.getBean(ResolvedHttpClientCapability.class).state()) + .isEqualTo(ResolvedHttpClientCapability.State.DISABLED_VERIFIED); + assertThat(context.getBeansOfType(OutboundHttpClient.class)).isEmpty(); + }); + } + + @Test + void actualApplicationYamlActiveFailsAtReadinessRatherThanLegacyConflict() { + applicationYamlRunner + .withBean( + HttpOperationCatalogRegistry.class, HttpClientCompositionConfigTest::activeCatalog) + .withPropertyValues( + "ca-skeleton.capabilities.http-client.expected-state=ACTIVE", + "ca-skeleton.capabilities.http-client.bindings.partner=jdk-r1", + "ca-skeleton.providers.http-client.jdk-r1.destinations.partner.operation-catalog=partner-v1", + "ca-skeleton.providers.http-client.jdk-r1.destinations.partner.profile=BUFFERED_CLASSIC") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure().getMessage()) + .contains("httpclient-static-buffered") + .contains("NOT_IMPLEMENTED") + .doesNotContain("legacy"); + }); + } + + private static HttpOperationCatalogRegistry activeCatalog() { + HttpDestinationId destination = new HttpDestinationId("partner"); + HttpOperationDescriptor operation = + new HttpOperationDescriptor( + new HttpOperationId("partner.fetch.v1"), + destination, + 1, + HttpOperationDescriptor.Method.GET, + "/items/{id}", + HttpOperationDescriptor.OperationSemantics.SAFE_READ, + HttpOperationDescriptor.RequestMode.NONE, + HttpOperationDescriptor.ResponseMode.BUFFERED, + Set.of(200), + 0, + 1, + 1024); + return new HttpOperationCatalogRegistry( + Map.of( + new HttpClientCanonicalConfiguration.OperationCatalogId("partner-v1"), + new HttpOperationCatalog(List.of(operation)))); + } + + private static String[] beanNamesFor(ApplicationContext context, String className) { + try { + return context.getBeanNamesForType(Class.forName(className)); + } catch (ClassNotFoundException exception) { + return new String[0]; + } + } + + private static void loadApplicationYaml(ConfigurableApplicationContext context) { + try { + new YamlPropertySourceLoader() + .load("application.yml", new ClassPathResource("application.yml")) + .forEach(context.getEnvironment().getPropertySources()::addLast); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderTest.java new file mode 100644 index 0000000..190991e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisEnvironmentMaterialProviderTest.java @@ -0,0 +1,116 @@ +package dev.caskeleton.bootstrap.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.redis.security.RedisSecretReference; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisCredentialMaterial; +import dev.caskeleton.adapter.outbound.cache.redis.security.VersionedRedisTrustMaterial; +import dev.caskeleton.bootstrap.runtime.SecretSource; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; + +class RedisEnvironmentMaterialProviderTest { + + @Test + void resolvesOnlyAllowlistedEnvironmentReferencesAndDescribesRestartOnlyRotation() { + AtomicInteger resolutions = new AtomicInteger(); + SecretSource source = + key -> { + resolutions.incrementAndGet(); + return Optional.of( + key.endsWith("TRUST_PEM") + ? "-----BEGIN CERTIFICATE-----\ninvalid-test-body\n-----END CERTIFICATE-----" + : "private-material"); + }; + RedisEnvironmentCredentialMaterialProvider credentialProvider = + new RedisEnvironmentCredentialMaterialProvider(source); + RedisEnvironmentTrustMaterialProvider trustProvider = + new RedisEnvironmentTrustMaterialProvider(source); + + try (VersionedRedisCredentialMaterial credential = + credentialProvider.resolve( + RedisSecretReference.parse("secret://environment/APP_RATE_LIMIT_REDIS_PASSWORD")); + VersionedRedisTrustMaterial trust = + trustProvider.resolve( + RedisSecretReference.parse( + "secret://environment/APP_RATE_LIMIT_REDIS_TRUST_PEM"))) { + String credentialValue = credential.useSecret(String::new); + int trustBytes = trust.usePem(bytes -> bytes.length); + assertThat(credentialValue).isEqualTo("private-material"); + assertThat(trustBytes).isPositive(); + } + + assertThat(resolutions).hasValue(2); + assertThat(credentialProvider.descriptor().changeEventsSupported()).isFalse(); + assertThat(trustProvider.descriptor().refreshMode()) + .isEqualTo("restart-or-explicit-runtime-recomposition"); + } + + @Test + void unknownSchemeKeyBlankOversizeAndProviderFailuresAreSanitized() { + RedisEnvironmentCredentialMaterialProvider blank = + new RedisEnvironmentCredentialMaterialProvider(ignored -> Optional.of(" ")); + RedisEnvironmentCredentialMaterialProvider oversized = + new RedisEnvironmentCredentialMaterialProvider(ignored -> Optional.of("x".repeat(16_385))); + RedisEnvironmentCredentialMaterialProvider leaking = + new RedisEnvironmentCredentialMaterialProvider( + ignored -> { + throw new IllegalStateException("raw-secret-and-reference"); + }); + + for (org.assertj.core.api.ThrowableAssert.ThrowingCallable call : + java.util.List.of( + () -> + blank.resolve( + RedisSecretReference.parse("secret://environment/APP_CACHE_REDIS_PASSWORD")), + () -> + oversized.resolve( + RedisSecretReference.parse("secret://environment/APP_CACHE_REDIS_PASSWORD")), + () -> + leaking.resolve( + RedisSecretReference.parse("secret://environment/APP_CACHE_REDIS_PASSWORD")), + () -> + blank.resolve( + RedisSecretReference.parse("secret://vault/APP_CACHE_REDIS_PASSWORD")), + () -> + blank.resolve( + RedisSecretReference.parse("secret://environment/UNREGISTERED_SECRET")))) { + assertThatThrownBy(call) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Canonical Redis environment material resolution failed") + .hasMessageNotContaining("raw-secret") + .hasMessageNotContaining("APP_CACHE") + .hasNoCause(); + } + } + + @Test + void resolvesEveryCanonicalHmacReferenceShippedInApplicationYaml() throws Exception { + var properties = + new YamlPropertySourceLoader() + .load("application.yml", new ClassPathResource("application.yml")) + .getFirst(); + RedisEnvironmentCredentialMaterialProvider provider = + new RedisEnvironmentCredentialMaterialProvider( + ignored -> Optional.of("cHJvZHVjdGlvbi1zYWZlLWhhcmRlbmVkLXRlc3QtaG1hYy1tYXRlcmlhbA==")); + + for (String property : + java.util.List.of( + "ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference", + "ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference", + "ca-skeleton.capabilities.idempotency.key-hmac-secret-reference", + "ca-skeleton.capabilities.lease.key-hmac-secret-reference", + "ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference")) { + String reference = String.valueOf(properties.getProperty(property)); + try (VersionedRedisCredentialMaterial material = + provider.resolve(RedisSecretReference.parse(reference))) { + String resolved = material.useSecret(String::new); + assertThat(resolved).isNotBlank(); + } + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java index c06b5b4..fe5dc08 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java @@ -2,12 +2,14 @@ package dev.caskeleton.bootstrap.runtime; import static org.assertj.core.api.Assertions.assertThat; +import java.io.IOException; import java.util.Set; import java.util.TimeZone; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.availability.ApplicationAvailabilityAutoConfiguration; +import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointAutoConfiguration; @@ -20,6 +22,9 @@ import org.springframework.boot.health.contributor.Status; 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.MutablePropertySources; +import org.springframework.core.env.PropertySourcesPropertyResolver; +import org.springframework.core.io.ClassPathResource; /** * TDD contract test for feature-runtime-health-lifecycle-contract. @@ -67,14 +72,33 @@ class RuntimeHealthLifecycleContractTest { "management.health.readinessstate.enabled=true", // The three production group properties under test. "management.endpoint.health.probes.enabled=true", + "management.endpoint.health.validate-group-membership=false", "management.endpoint.health.group.liveness.include=livenessState", - "management.endpoint.health.group.readiness.include=readinessState,db", + "management.endpoint.health.group.readiness.include=readinessState,db,redisRequired", "management.endpoint.health.group.startup.include=readinessState"); // ========================================================================= // 1. Three health groups are configured // ========================================================================= + @Test + @DisplayName( + "shipped health groups keep Redis out of liveness and optional Redis out of readiness") + void shippedHealthGroupSettingsPreserveRedisDependencyTaxonomy() throws IOException { + MutablePropertySources sources = new MutablePropertySources(); + new YamlPropertySourceLoader() + .load("application", new ClassPathResource("application.yml")) + .forEach(sources::addLast); + PropertySourcesPropertyResolver properties = new PropertySourcesPropertyResolver(sources); + + assertThat(properties.getProperty("management.endpoint.health.group.liveness.include")) + .isEqualTo("livenessState"); + assertThat(properties.getProperty("management.endpoint.health.group.readiness.include")) + .isEqualTo("readinessState,db,redisRequired"); + assertThat(properties.getProperty("management.endpoint.health.validate-group-membership")) + .isEqualTo("false"); + } + @Test @DisplayName("health probes: liveness group is configured") void livenessGroupIsConfigured() { @@ -100,7 +124,7 @@ class RuntimeHealthLifecycleContractTest { assertThat(groups.get("readiness")) .as( "readiness group must be configured " - + "(management.endpoint.health.group.readiness.include=readinessState,db)") + + "(include=readinessState,db,redisRequired)") .isNotNull(); }); } @@ -140,7 +164,7 @@ class RuntimeHealthLifecycleContractTest { HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); var readiness = groups.get("readiness"); assertThat(readiness).as("readiness group must exist").isNotNull(); - // The group membership is defined by "include=readinessState,db". + // The group membership is defined by "include=readinessState,db,redisRequired". // isMember() returns true when the contributor name is in the include list. assertThat(readiness.isMember("db")) .as( @@ -150,6 +174,19 @@ class RuntimeHealthLifecycleContractTest { }); } + @Test + @DisplayName("readiness includes required Redis but excludes optional cache Redis") + void readinessIncludesOnlyTheRequiredRedisContributor() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + var readiness = ctx.getBean(HealthEndpointGroups.class).get("readiness"); + assertThat(readiness).as("readiness group must exist").isNotNull(); + assertThat(readiness.isMember("redisRequired")).isTrue(); + assertThat(readiness.isMember("redisOptional")).isFalse(); + }); + } + @Test @DisplayName("liveness group does NOT include db (liveness is independent of REQUIRED deps)") void livenessGroupDoesNotIncludeDb() { @@ -164,6 +201,8 @@ class RuntimeHealthLifecycleContractTest { "liveness group must NOT include 'db' " + "(a DOWN DB must not flip liveness — the JVM can still continue)") .isFalse(); + assertThat(liveness.isMember("redisRequired")).isFalse(); + assertThat(liveness.isMember("redisOptional")).isFalse(); }); } @@ -212,9 +251,10 @@ class RuntimeHealthLifecycleContractTest { void jvmDefaultTimezoneIsUtc() { assertThat(TimeZone.getDefault().getID()) .as( - "JVM default timezone must be UTC — enforced by -Duser.timezone=UTC in the " - + "app-bootstrap test task. A drift here means the test JVM arg was removed. " - + "Production UTC is owned by feature-container-runtime-contract (TZ=UTC in Dockerfile).") + "JVM default timezone must be UTC — enforced by -Duser.timezone=UTC in the" + + " app-bootstrap test task. A drift here means the test JVM arg was removed." + + " Production UTC is owned by feature-container-runtime-contract (TZ=UTC in" + + " Dockerfile).") .isEqualTo("UTC"); } @@ -223,7 +263,7 @@ class RuntimeHealthLifecycleContractTest { // Mirrors the liveness/readiness isMember tests (sections 1 and 2). // The startup group is configured with include=readinessState — assert // membership explicitly to give the startup group the same coverage parity - // as liveness (livenessState) and readiness (readinessState,db). + // as liveness (livenessState) and readiness (readinessState,db,redisRequired). // ========================================================================= @Test diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java index 0a6ecbc..151630f 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java @@ -3,6 +3,7 @@ package dev.caskeleton.bootstrap.runtime; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import java.util.Arrays; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -30,6 +31,8 @@ class SecretSourceValidatorTest { "APP_EXTERNAL_API_KEY=real-api-key", "APP_CACHE_REDIS_PASSWORD=real-redis-password", "APP_CACHE_REDIS_KEY_HMAC_SECRET=real-redis-key-hmac-secret", + "APP_RATE_LIMIT_REDIS_PASSWORD=real-rate-limit-redis-password", + "APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=real-rate-limit-redis-key-hmac-secret", "APP_PRIVACY_PSEUDONYMIZATION_SALT=real-salt" }; } @@ -106,6 +109,102 @@ class SecretSourceValidatorTest { .run(context -> assertThat(context).hasNotFailed()); } + @Test + void enabledRateLimitRedisRequiresItsDedicatedSecretsInProd() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + .withPropertyValues( + "ca-skeleton.capabilities.rate-limit.provider=redis", + "APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET"); + }); + } + + @Test + void disabledRateLimitRedisDoesNotRequireItsDedicatedSecretsInProd() { + String[] baselineSecrets = + Arrays.stream(allRequiredSecretsPresent()) + .filter(value -> !value.startsWith("APP_RATE_LIMIT_REDIS_")) + .toArray(String[]::new); + + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(baselineSecrets) + .withPropertyValues("ca-skeleton.capabilities.rate-limit.provider=disabled") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void transportEnableAloneDoesNotRequireRedisProviderSecretsInProd() { + String[] baselineSecrets = + Arrays.stream(allRequiredSecretsPresent()) + .filter(value -> !value.startsWith("APP_RATE_LIMIT_REDIS_")) + .toArray(String[]::new); + + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(baselineSecrets) + .withPropertyValues("app.rate-limit.enabled=true") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void selectedRedisIdempotencyRequiresItsHmacMaterialInProd() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + .withPropertyValues( + "ca-skeleton.capabilities.idempotency.provider=redis", + "APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET"); + }); + } + + @Test + void selectedRedisEfficiencyLeaseRequiresItsHmacMaterialInProd() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + .withPropertyValues( + "ca-skeleton.capabilities.lease.provider=redis", "APP_LEASE_REDIS_KEY_HMAC_SECRET=") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_LEASE_REDIS_KEY_HMAC_SECRET"); + }); + } + + @Test + void redisSessionModeRequiresItsHmacMaterialInProd() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "APP_SESSION_REDIS_PASSWORD=real-session-password", + "APP_SESSION_REDIS_KEY_HMAC_SECRET=") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_SESSION_REDIS_KEY_HMAC_SECRET"); + }); + } + @Configuration static class ValidatorConfig { @Bean diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfigTest.java new file mode 100644 index 0000000..6ccd809 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/redis/RedisHealthContributorConfigTest.java @@ -0,0 +1,191 @@ +package dev.caskeleton.bootstrap.runtime.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Capability; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.EvictionAttestation; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.EvictionPolicy; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Reason; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Role; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.RoleHealth; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.Snapshot; +import dev.caskeleton.shared.health.RedisHealthSnapshotProvider.State; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class RedisHealthContributorConfigTest { + + private static final Instant OBSERVED_AT = Instant.parse("2026-07-29T01:02:03Z"); + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(RedisHealthContributorConfig.class); + + @Test + void noRoleBindingCreatesNoRedisHealthContributor() { + runner + .withBean(RedisHealthSnapshotProvider.class, () -> () -> snapshot()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("redisRequired"); + assertThat(context).doesNotHaveBean("redisOptional"); + }); + } + + @Test + void declaredButUnselectedRoleCreatesNoRedisHealthContributor() { + runner + .withPropertyValues("ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main") + .withBean(RedisHealthSnapshotProvider.class, () -> () -> snapshot()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("redisRequired"); + assertThat(context).doesNotHaveBean("redisOptional"); + }); + } + + @Test + void optionalCacheOutageStaysUpAndReportsOnlyDegradedSanitizedDetail() { + runner + .withPropertyValues( + "ca-skeleton.capabilities.cache.bindings.default=redis", + "ca-skeleton.providers.redis.roles.cache.deployment-id=cache-main") + .withBean( + RedisHealthSnapshotProvider.class, + () -> + () -> + snapshot( + role( + Role.CACHE, + false, + Set.of(Capability.CACHE), + State.UNAVAILABLE, + Reason.SEMANTIC_PROGRAM_FAILED, + EvictionPolicy.ALLKEYS_LFU))) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("redisOptional"); + assertThat(context).doesNotHaveBean("redisRequired"); + + var health = context.getBean("redisOptional", HealthIndicator.class).health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()) + .containsEntry("state", "DEGRADED") + .doesNotContainKey("exception"); + assertThat(health.toString()) + .contains("SEMANTIC_PROGRAM_FAILED", "INCOMPLETE") + .doesNotContain("cache-main"); + }); + } + + @Test + void requiredCoordinationOutageTurnsRequiredContributorDown() { + runner + .withPropertyValues( + "ca-skeleton.capabilities.rate-limit.provider=redis", + "ca-skeleton.providers.redis.roles.coordination.deployment-id=coord-main") + .withBean( + RedisHealthSnapshotProvider.class, + () -> + () -> + snapshot( + role( + Role.COORDINATION, + true, + Set.of(Capability.RATE_LIMIT, Capability.IDEMPOTENCY), + State.UNAVAILABLE, + Reason.SEMANTIC_PROGRAM_ACL_DENIED, + EvictionPolicy.NOEVICTION))) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("redisRequired"); + assertThat(context).doesNotHaveBean("redisOptional"); + + var health = context.getBean("redisRequired", HealthIndicator.class).health(); + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()) + .containsEntry("state", "UNAVAILABLE") + .containsEntry("missingRoles", List.of()); + assertThat(health.toString()).contains("SEMANTIC_PROGRAM_ACL_DENIED"); + }); + } + + @Test + void missingRequiredSessionSnapshotFailsClosed() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.providers.redis.roles.session.deployment-id=session-main") + .withBean(RedisHealthSnapshotProvider.class, () -> () -> snapshot()) + .run( + context -> { + assertThat(context).hasNotFailed(); + var health = context.getBean("redisRequired", HealthIndicator.class).health(); + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()) + .containsEntry("missingRoles", List.of(Role.SESSION.name())); + }); + } + + @Test + void availableRequiredRoleIsUpWhileEvictionRemainsExplicitlyConfigOnly() { + runner + .withPropertyValues( + "ca-skeleton.security.auth-mode=redis-session", + "ca-skeleton.providers.redis.roles.session.deployment-id=session-main") + .withBean( + RedisHealthSnapshotProvider.class, + () -> + () -> + snapshot( + role( + Role.SESSION, + true, + Set.of(Capability.SESSION), + State.AVAILABLE, + Reason.SEMANTIC_PROBE_SUCCEEDED, + EvictionPolicy.NOEVICTION))) + .run( + context -> { + assertThat(context).hasNotFailed(); + var health = context.getBean("redisRequired", HealthIndicator.class).health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.toString()) + .contains(EvictionAttestation.CONFIGURED_EXPECTATION_ONLY.name(), "INCOMPLETE"); + }); + } + + private static Snapshot snapshot(RoleHealth... roles) { + return new Snapshot(OBSERVED_AT, List.of(roles)); + } + + private static RoleHealth role( + Role role, + boolean required, + Set capabilities, + State state, + Reason reason, + EvictionPolicy eviction) { + return new RoleHealth( + role, + "deployment-id-never-exposed", + required, + eviction, + EvictionAttestation.CONFIGURED_EXPECTATION_ONLY, + capabilities, + state, + reason, + OBSERVED_AT, + 0, + false); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfigTest.java new file mode 100644 index 0000000..9d37b1e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfigTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.bootstrap.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class AuthenticationModeCompositionConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(AuthenticationModeCompositionConfig.class); + + @Test + void jwtModeRequiresOnlyJwtInfrastructure() { + runner + .withBean("jwtDecoder", Object.class, Object::new) + .withPropertyValues("ca-skeleton.security.auth-mode=jwt") + .run(context -> assertThat(context).hasNotFailed()); + + runner + .withBean("jwtDecoder", Object.class, Object::new) + .withBean("redisVersionedSessionRepository", Object.class, Object::new) + .withPropertyValues("ca-skeleton.security.auth-mode=jwt") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessage( + "Authentication mode composition is not exclusive for JWT: [Redis Session repository/filter is active]"); + }); + } + + @Test + void redisSessionModeRequiresCompleteSessionInfrastructureAndNoJwtDecoder() { + runner + .withBean("redisVersionedSessionRepository", Object.class, Object::new) + .withBean("springSessionRepositoryFilter", Object.class, Object::new) + .withPropertyValues("ca-skeleton.security.auth-mode=redis-session") + .run(context -> assertThat(context).hasNotFailed()); + + runner + .withBean("jwtDecoder", Object.class, Object::new) + .withBean("redisVersionedSessionRepository", Object.class, Object::new) + .withPropertyValues("ca-skeleton.security.auth-mode=redis-session") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessage( + "Authentication mode composition is not exclusive for REDIS_SESSION: [jwtDecoder is active, Redis Session repository/filter is incomplete]"); + }); + } +} diff --git a/src/app-bootstrap/src/test/resources/application-test.yml b/src/app-bootstrap/src/test/resources/application-test.yml index 03a064e..50e378e 100644 --- a/src/app-bootstrap/src/test/resources/application-test.yml +++ b/src/app-bootstrap/src/test/resources/application-test.yml @@ -150,15 +150,3 @@ ca-skeleton: sampling-rate: 1.0 privacy: pseudonymization-salt: __LOCAL_DEV_test_salt - -# feature-outbound-http-client-baseline: required timeout properties for any test -# context that scans dev.caskeleton (OutboundHttpSettings requires non-zero timeouts). -app: - outbound: - http: - connect-timeout: 2s - read-timeout: 5s - global-call-timeout: 10s - retry-enabled: false - circuit-breaker-enabled: false - response-size-limit: 10MB diff --git a/src/application-core/CLAUDE.md b/src/application-core/CLAUDE.md index 9365103..e0b1aca 100644 --- a/src/application-core/CLAUDE.md +++ b/src/application-core/CLAUDE.md @@ -56,13 +56,37 @@ Package root: `dev.caskeleton.application`. | `usecase.QueryUseCase` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. | | `command.Command` | Marker for write intents. Plain immutable types built from domain values. | | `query.Query` | Marker for read intents. Plain immutable types built from domain values. | -| `transaction.TransactionPort` | Outbound port for transactional boundaries. Implemented by `adapter-persistence`. | +| `transaction.TransactionPort` | Outbound port for join-capable write/read, physical root-only write, and independent write boundaries. Implemented by `adapter-persistence`. | +| `transaction.NestedRootTransactionRejectedException` | Fail-fast signal raised before action/provider side effects when `inRootWrite` detects an actual ambient transaction. | | `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. | | `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. | | `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. | | `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. | | `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. | +## Notification R1 application boundary + +- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned + kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and + writer-cutover command contracts. +- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort` + returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning + handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence + entity, compiled adapter binding and raw recipient/template payload types are forbidden here. +- Provider calls run outside database transactions. Dispatch and reconciliation use bounded + claim/authorize/finalize transactions with opaque claim/version/execution tokens; an + `INDETERMINATE` submission is terminal and must not be blindly retried. +- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce + and complaint facts may request technical suppression; consent/unsubscribe policy is outside this + capability. +- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile + registries are application-owned exact inputs; signed inventory/quiescence verification is + delegated to narrow verifier ports and the persistence operation must enforce locked durable + state/journal invariants. +- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking, + provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the + notification/persistence/bootstrap adapters. + ## Naming convention - Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit. @@ -103,10 +127,15 @@ application-core never self-registers with a DI framework. | Use case shape | `transactionMode` | TransactionPort call | When | |---|---|---|---| | Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. | +| Physical-root write command | `WRITE` | `tx.inRootWrite(...)` | Only when orchestration must prove there is no ambient transaction and expose a result after commit. | | Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. | | Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. | `NESTED` and `NEVER` propagation are forbidden. +`inRootWrite` MUST reject an actual ambient transaction before invoking its action or +`PlatformTransactionManager`; it MUST NOT emulate root-only behavior with `REQUIRES_NEW`. +Both `inWrite` and `inRootWrite` satisfy the direct boundary fitness rule for a +`WRITE_REPOSITORY + WRITE` use case. READ and REQUIRES_NEW mappings remain exclusive. ### Callback signature contract (D11) diff --git a/src/application-core/README.md b/src/application-core/README.md index 124f0c4..8d2d796 100644 --- a/src/application-core/README.md +++ b/src/application-core/README.md @@ -14,6 +14,12 @@ `verifyApplicationCoreDependencyPurity`와 ArchUnit `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`가 이 계약을 자동 검증한다. +Cache 진단도 같은 원칙을 따른다. `CacheObservationEvent`는 code-owned bounded cache name, +local/Redis tier, enum outcome과 finite duration/count만 표현하며 semantic key, user/tenant ID, +endpoint를 담지 않는다. `CacheObservationPort`는 이 event를 전달하는 framework-free 경계이고, +Micrometer meter/tag 렌더링은 Redis adapter가 소유한다. 관측 실패는 cache lookup/invalidation +결과를 바꾸지 않는다. + --- ## 유스케이스 계약 (usecase / command / query / capability) @@ -71,13 +77,21 @@ - **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional` 을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통 `SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다. - application/domain 을 프레임워크-free 로 유지하는 핵심 장치. -- 세 가지 경계: +application/domain 을 프레임워크-free 로 유지하는 핵심 장치. +- 네 가지 경계: - `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본. + - `inRootWrite` — 물리 root 전용 REQUIRED + read-write, `READ_COMMITTED`. 실제 ambient + transaction 이 하나라도 있으면 action 실행 전에 + `NestedRootTransactionRejectedException` 으로 거부한다. 성공 값은 commit 이 끝난 뒤에만 + 호출자에게 반환되며, commit 실패는 그대로 전파된다. - `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본. - `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한 유스케이스(outbox/audit/compensation)에서만 허용. -- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질 +- **root-only 사용 조건**: `inRootWrite` 는 join 가능한 일반 command 경계의 대체물이 아니다. + 외부 효과를 commit 이후에만 시작해야 하는 orchestration처럼 물리 root를 증명해야 하는 경우에만 + 쓴다. 기존 transaction 안에서 `REQUIRES_NEW` 로 몰래 분리하지 않고 fail-fast하므로, 호출자는 + transaction 없는 진입점에서 이 경계를 시작해야 한다. +- **콜백 시그니처(D11)**: 네 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질 수 없다. Spring `TransactionCallback` 제약과 동일하다. 그래서 호출자는 도메인 checked exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`). `IOException` → `UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이 @@ -93,7 +107,33 @@ **금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 + 데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라. - **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서 - `@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출. + `@Transactional` 직접 사용, `inRootWrite` 의 ambient transaction 진입, `inNew` 의 per-record + 루프 호출. + +--- + +## Notification R1 오케스트레이션 경계 + +`dev.caskeleton.application.notification`은 알림 vendor 구현이 아니라 알림 capability의 순수 +애플리케이션 계약이다. + +- 입력은 typed recipient/template value와 코드 소유 `NotificationKindPolicy`로 제한한다. feature가 + 만든 `NotificationIntentDraft`는 `NotificationPlanPort`에서 immutable + `NotificationFrozenPlan`으로 고정되고, append/inline 포트는 이 plan만 소비한다. +- dispatch는 claim → reserve/authorize → provider call → terminal-once finalize 순서다. 짧은 DB + transaction 사이에서 provider를 호출하며, opaque claim/version/execution token으로 stale 결과를 + 거부한다. submission certainty가 `INDETERMINATE`면 blind retry나 fallback을 하지 않는다. +- receipt reducer는 fact 순서와 무관한 monotonic projection을 만든다. hard bounce/complaint만 + technical suppression 후보이고, business consent/unsubscribe는 다른 capability가 소유한다. +- admission/reconciliation/maintenance는 bounded batch와 주입된 `Clock`을 사용한다. scheduler는 + 이 유스케이스만 호출하며 store/provider 포트를 직접 조율하지 않는다. +- legacy→canonical writer cutover는 exact route/generation/profile registry, root-only commit, + 서명된 inventory/quiescence evidence와 closed transition action으로 표현한다. 애플리케이션은 + verifier/operation 포트의 입력 계약을 강제하고, 실제 서명 검증·행 잠금·불변 journal·provider + egress 차단은 후속 adapter 구현이 증명해야 한다. + +현재 증거 등급은 **R1 application contract with fakes**다. PostgreSQL DDL/locking, provider +protocol, receipt ingress, runtime wiring을 포함한 R2/R3 완료 주장이 아니다. ### TransactionMode diff --git a/src/application-core/build.gradle b/src/application-core/build.gradle index a643938..d4584b4 100644 --- a/src/application-core/build.gradle +++ b/src/application-core/build.gradle @@ -3,3 +3,29 @@ dependencies { implementation project(':shared-contract') } + +sourceSets { + redisPolicyContractTest { + java.srcDir 'src/redisPolicyContractTest/java' + resources.srcDir 'src/redisPolicyContractTest/resources' + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +configurations { + redisPolicyContractTestImplementation.extendsFrom testImplementation + redisPolicyContractTestCompileOnly.extendsFrom testCompileOnly + redisPolicyContractTestRuntimeOnly.extendsFrom testRuntimeOnly +} + +tasks.register('redisPolicyContractTest', Test) { + group = 'redis verification' + description = 'Runs provider-neutral Redis policy contracts without a Redis/framework dependency.' + testClassesDirs = sourceSets.redisPolicyContractTest.output.classesDirs + classpath = sourceSets.redisPolicyContractTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} diff --git a/src/application-core/gradle.lockfile b/src/application-core/gradle.lockfile index ff6d49b..d97cb37 100644 --- a/src/application-core/gradle.lockfile +++ b/src/application-core/gradle.lockfile @@ -1,40 +1,40 @@ # 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. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.puppycrawl.tools:checkstyle:13.5.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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 @@ -50,31 +50,31 @@ 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.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,testCompileClasspath +org.assertj:assertj-core:3.27.6=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,redisPolicyContractTestCompileClasspath,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.pcollections:pcollections:4.0.1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java new file mode 100644 index 0000000..8eaadd4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java @@ -0,0 +1,457 @@ +package dev.caskeleton.application.cache; + +import java.time.Clock; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.Objects; + +/** + * Framework-free cache-aside orchestration with bounded local coalescing and source concurrency. + * Construct one instance per semantic cache region so its policy and protection bounds are shared. + * + *

When optional refresh coordination is enabled, the soft-lease owner refreshes synchronously. + * This executor does not schedule an asynchronous stale-while-revalidate task. A valid stale + * contender or a valid stale request facing coordination failure returns immediately instead. + */ +public final class CacheAsideExecutor { + + private final CacheAsidePolicy policy; + private final Clock clock; + private final CacheSingleFlight> singleFlight; + private final CacheSourceBulkhead sourceBulkhead; + private final CacheRefreshCoordinationPort refreshCoordinator; + private final CacheRefreshCoordinationPolicy refreshCoordinationPolicy; + + public CacheAsideExecutor(CacheAsidePolicy policy, Clock clock) { + this(policy, clock, null, null); + } + + public CacheAsideExecutor( + CacheAsidePolicy policy, + Clock clock, + CacheRefreshCoordinationPort refreshCoordinator, + CacheRefreshCoordinationPolicy refreshCoordinationPolicy) { + this.policy = Objects.requireNonNull(policy, "policy must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.refreshCoordinator = refreshCoordinator; + if (refreshCoordinator == null) { + if (refreshCoordinationPolicy != null) { + throw new IllegalArgumentException("refresh coordination policy requires a coordinator"); + } + this.refreshCoordinationPolicy = null; + } else { + this.refreshCoordinationPolicy = + Objects.requireNonNull( + refreshCoordinationPolicy, "refreshCoordinationPolicy must be non-null") + .validateAgainst(policy); + } + singleFlight = + new CacheSingleFlight<>(policy.maximumInFlightSourceKeys(), policy.maximumWaitersPerKey()); + sourceBulkhead = new CacheSourceBulkhead(policy.maximumConcurrentSourceLoads()); + } + + public CacheResult getOrLoad( + K key, CacheRegionPort region, CacheSourceLoader sourceLoader) { + Objects.requireNonNull(key, "key must be non-null"); + Objects.requireNonNull(region, "region must be non-null"); + Objects.requireNonNull(sourceLoader, "sourceLoader must be non-null"); + + CacheLookup lookup = + Objects.requireNonNull(region.lookup(key), "cache lookup must be non-null"); + StaleCandidate stale = null; + boolean hardMiss = false; + RefillCondition refillCondition = RefillCondition.absent(CacheWriteCondition.unavailable()); + if (lookup instanceof CacheLookup.Hit hit) { + if (hit.freshness() == CacheLookup.Freshness.FRESH) { + return new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision()); + } + if (!hit.observationToken().usable()) { + return new CacheResult.IncompatibleSchema<>( + CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST); + } + stale = + new StaleCandidate<>( + hit.value(), hit.sourceRevision(), hit.hardExpiresAt(), hit.observationToken()); + refillCondition = RefillCondition.observed(hit.observationToken(), hit.writeCondition()); + } else if (lookup instanceof CacheLookup.NegativeHit negative) { + return new CacheResult.NegativeHit<>(negative.reason()); + } else if (lookup instanceof CacheLookup.Miss miss) { + refillCondition = RefillCondition.absent(miss.writeCondition()); + hardMiss = true; + } else if (lookup instanceof CacheLookup.IncompatibleSchema incompatible) { + if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST) { + return new CacheResult.IncompatibleSchema<>(incompatible.category(), incompatible.policy()); + } + if (!incompatible.observationToken().usable()) { + return new CacheResult.IncompatibleSchema<>( + incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST); + } + refillCondition = + RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition()); + } else if (lookup instanceof CacheLookup.Unavailable unavailable) { + refillCondition = RefillCondition.absent(unavailable.writeCondition()); + } + + RefillCondition selectedCondition = refillCondition; + StaleCandidate selectedStale = stale; + boolean selectedHardMiss = hardMiss; + CacheSingleFlight.Outcome> flight = + singleFlight.execute( + key, + policy.maximumWaitDuration(), + () -> + loadFromSource( + key, region, sourceLoader, selectedCondition, selectedStale, selectedHardMiss)); + if (flight instanceof CacheSingleFlight.Rejected> rejected) { + return new CacheResult.Rejected<>( + switch (rejected.reason()) { + case MAXIMUM_IN_FLIGHT_KEYS -> CacheResult.RejectionReason.MAXIMUM_IN_FLIGHT_KEYS; + case MAXIMUM_WAITERS -> CacheResult.RejectionReason.MAXIMUM_WAITERS; + case WAIT_TIMEOUT -> CacheResult.RejectionReason.WAIT_TIMEOUT; + }); + } + if (flight instanceof CacheSingleFlight.Interrupted>) { + return new CacheResult.Cancelled<>(); + } + SourceAttempt attempt = ((CacheSingleFlight.Completed>) flight).value(); + return toResult(attempt, stale); + } + + int inFlightWaiterCount(K key) { + return singleFlight.waiterCount(key); + } + + private SourceAttempt loadFromSource( + K key, + CacheRegionPort region, + CacheSourceLoader sourceLoader, + RefillCondition refillCondition, + StaleCandidate stale, + boolean hardMiss) { + CacheSourceBulkhead.Outcome> admitted = + sourceBulkhead.execute( + policy.sourceAdmissionWait(), + () -> invokeSource(key, region, sourceLoader, refillCondition, stale, hardMiss)); + if (admitted instanceof CacheSourceBulkhead.Rejected>) { + return new SourceRejected<>(); + } + if (admitted instanceof CacheSourceBulkhead.Interrupted>) { + return new SourceInterrupted<>(); + } + return ((CacheSourceBulkhead.Completed>) admitted).value(); + } + + private SourceAttempt invokeSource( + K key, + CacheRegionPort region, + CacheSourceLoader sourceLoader, + RefillCondition refillCondition, + StaleCandidate stale, + boolean hardMiss) { + CacheRefreshClaimAttempt ownedAttempt = null; + RefillCondition selectedCondition = refillCondition; + try { + if (shouldCoordinate(stale, hardMiss)) { + CacheRefreshClaimAttempt attempt = + Objects.requireNonNull( + refreshCoordinator.newAttempt(), "cache refresh claim attempt must be non-null"); + if (!attempt.usable()) { + throw new IllegalStateException( + "enabled cache refresh coordinator returned an unusable attempt"); + } + CacheRefreshClaimOutcome claim = claimWithOneUncertainRetry(key, attempt); + if (claim instanceof CacheRefreshClaimOutcome.Contended) { + SourceAttempt deferred = staleDeferral(stale, claim); + if (deferred != null) { + return deferred; + } + if (hardMiss + && refreshCoordinationPolicy.hardMissPolicy() + == CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD) { + if (!boundedWait(refreshCoordinationPolicy.hardMissWait())) { + return new SourceInterrupted<>(); + } + Recheck recheck = recheck(region, key); + if (recheck.immediateResult() != null) { + return new ImmediateResult<>(recheck.immediateResult()); + } + selectedCondition = recheck.refillCondition(); + } + } else if (claim instanceof CacheRefreshClaimOutcome.Unavailable + || claim instanceof CacheRefreshClaimOutcome.Indeterminate) { + SourceAttempt deferred = staleDeferral(stale, claim); + if (deferred != null) { + return deferred; + } + } else if (claim instanceof CacheRefreshClaimOutcome.Claimed + || claim instanceof CacheRefreshClaimOutcome.AlreadyOwned) { + ownedAttempt = attempt; + Recheck recheck = recheck(region, key); + if (recheck.immediateResult() != null) { + return new ImmediateResult<>(recheck.immediateResult()); + } + selectedCondition = recheck.refillCondition(); + } + } + return invokeSourceDirect(key, region, sourceLoader, selectedCondition); + } finally { + if (ownedAttempt != null) { + Objects.requireNonNull( + refreshCoordinator.release(key, ownedAttempt), + "cache refresh release outcome must be non-null"); + } + } + } + + private SourceAttempt invokeSourceDirect( + K key, + CacheRegionPort region, + CacheSourceLoader sourceLoader, + RefillCondition refillCondition) { + Instant deadline; + try { + deadline = clock.instant().plus(policy.sourceLoadDeadline()); + } catch (DateTimeException exception) { + throw new IllegalStateException("cache source deadline cannot be represented", exception); + } + CacheCancellationToken cancellation = new CacheCancellationToken(deadline, clock); + if (cancellation.isInterrupted()) { + return new SourceInterrupted<>(); + } + + SourceLoadOutcome outcome = + Objects.requireNonNull( + sourceLoader.load(key, cancellation), "source loader outcome must be non-null"); + if (cancellation.isInterrupted() || outcome instanceof SourceLoadOutcome.Cancelled) { + return new SourceInterrupted<>(); + } + if (cancellation.isDeadlineExceeded()) { + return new SourceTimedOut<>(); + } + if (outcome instanceof SourceLoadOutcome.Loaded loaded) { + CacheRecordOutcome recorded = + Objects.requireNonNull( + region.record( + key, + loaded.value(), + new CacheRecordMetadata( + loaded.sourceRevision(), + refillCondition.intent(), + refillCondition.observationToken(), + refillCondition.writeCondition())), + "cache record outcome must be non-null"); + return new SourceResolved<>(outcome, recorded); + } + if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent absent) { + CacheRecordOutcome recorded = + Objects.requireNonNull( + region.recordAbsent( + key, + absent.reason(), + new CacheRecordMetadata( + absent.sourceRevision(), + refillCondition.intent(), + refillCondition.observationToken(), + refillCondition.writeCondition())), + "negative cache record outcome must be non-null"); + return new SourceResolved<>(outcome, recorded); + } + return new SourceResolved<>(outcome, null); + } + + private boolean shouldCoordinate(StaleCandidate stale, boolean hardMiss) { + if (refreshCoordinator == null || !refreshCoordinator.enabled()) { + return false; + } + return stale != null + || (hardMiss + && refreshCoordinationPolicy.hardMissPolicy() + == CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD); + } + + private CacheRefreshClaimOutcome claimWithOneUncertainRetry( + K key, CacheRefreshClaimAttempt attempt) { + CacheRefreshClaimOutcome first = + Objects.requireNonNull( + refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()), + "cache refresh claim outcome must be non-null"); + if (first instanceof CacheRefreshClaimOutcome.Indeterminate) { + return Objects.requireNonNull( + refreshCoordinator.claim(key, attempt, refreshCoordinationPolicy.leaseTimeToLive()), + "cache refresh claim retry outcome must be non-null"); + } + return first; + } + + private SourceAttempt staleDeferral(StaleCandidate stale, CacheRefreshClaimOutcome claim) { + if (stale != null && clock.instant().isBefore(stale.hardExpiresAt())) { + CacheResult.RefreshDeferralReason reason = + claim instanceof CacheRefreshClaimOutcome.Contended + ? CacheResult.RefreshDeferralReason.CONTENDED + : claim instanceof CacheRefreshClaimOutcome.Unavailable + ? CacheResult.RefreshDeferralReason.COORDINATION_UNAVAILABLE + : CacheResult.RefreshDeferralReason.COORDINATION_INDETERMINATE; + return new ImmediateResult<>( + new CacheResult.StaleRefreshDeferred<>(stale.value(), stale.sourceRevision(), reason)); + } + return null; + } + + private boolean boundedWait(java.time.Duration duration) { + try { + long milliseconds = duration.toMillis(); + int nanoseconds = (int) duration.minusMillis(milliseconds).toNanos(); + Thread.sleep(milliseconds, nanoseconds); + return true; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } + } + + private Recheck recheck(CacheRegionPort region, K key) { + CacheLookup lookup = + Objects.requireNonNull(region.lookup(key), "cache recheck must be non-null"); + if (lookup instanceof CacheLookup.Hit hit) { + if (hit.freshness() == CacheLookup.Freshness.FRESH) { + return Recheck.immediate(new CacheResult.FreshHit<>(hit.value(), hit.sourceRevision())); + } + if (!hit.observationToken().usable()) { + return Recheck.immediate( + new CacheResult.IncompatibleSchema<>( + CacheLookup.SchemaCategory.UNKNOWN_ENVELOPE, CacheLookup.SchemaPolicy.FAIL_FAST)); + } + return Recheck.refill(RefillCondition.observed(hit.observationToken(), hit.writeCondition())); + } + if (lookup instanceof CacheLookup.NegativeHit negative) { + return Recheck.immediate(new CacheResult.NegativeHit<>(negative.reason())); + } + if (lookup instanceof CacheLookup.Miss miss) { + return Recheck.refill(RefillCondition.absent(miss.writeCondition())); + } + if (lookup instanceof CacheLookup.IncompatibleSchema incompatible) { + if (incompatible.policy() == CacheLookup.SchemaPolicy.FAIL_FAST + || !incompatible.observationToken().usable()) { + return Recheck.immediate( + new CacheResult.IncompatibleSchema<>( + incompatible.category(), CacheLookup.SchemaPolicy.FAIL_FAST)); + } + return Recheck.refill( + RefillCondition.observed(incompatible.observationToken(), incompatible.writeCondition())); + } + CacheLookup.Unavailable unavailable = (CacheLookup.Unavailable) lookup; + return Recheck.refill(RefillCondition.absent(unavailable.writeCondition())); + } + + private CacheResult toResult(SourceAttempt attempt, StaleCandidate stale) { + if (attempt instanceof ImmediateResult immediate) { + return immediate.result(); + } + if (attempt instanceof SourceRejected) { + return new CacheResult.Rejected<>(CacheResult.RejectionReason.SOURCE_OVERLOADED); + } + if (attempt instanceof SourceTimedOut) { + return new CacheResult.Rejected<>(CacheResult.RejectionReason.LOAD_TIMEOUT); + } + if (attempt instanceof SourceInterrupted) { + return new CacheResult.Cancelled<>(); + } + SourceResolved resolved = (SourceResolved) attempt; + SourceLoadOutcome outcome = resolved.outcome(); + if (outcome instanceof SourceLoadOutcome.Loaded loaded) { + return new CacheResult.LoadedFromSource<>( + loaded.value(), loaded.sourceRevision(), resolved.recordOutcome()); + } + if (outcome instanceof SourceLoadOutcome.AuthoritativeAbsent absent) { + return new CacheResult.AuthoritativeAbsent<>( + absent.reason(), absent.sourceRevision(), resolved.recordOutcome()); + } + if (outcome instanceof SourceLoadOutcome.TransientFailure transientFailure) { + if (stale != null + && policy.serveStaleOnTransientFailure() + && clock.instant().isBefore(stale.hardExpiresAt())) { + return new CacheResult.StaleFallbackAfterTransientFailure<>( + stale.value(), stale.sourceRevision(), transientFailure.failure()); + } + return new CacheResult.SourceFailed<>( + transientFailure.failure(), CacheResult.SourceFailureKind.TRANSIENT); + } + if (outcome instanceof SourceLoadOutcome.PermanentFailure permanentFailure) { + return new CacheResult.SourceFailed<>( + permanentFailure.failure(), CacheResult.SourceFailureKind.PERMANENT); + } + return new CacheResult.Cancelled<>(); + } + + private sealed interface SourceAttempt + permits SourceResolved, SourceRejected, SourceInterrupted, SourceTimedOut, ImmediateResult {} + + private record SourceResolved(SourceLoadOutcome outcome, CacheRecordOutcome recordOutcome) + implements SourceAttempt { + + private SourceResolved { + Objects.requireNonNull(outcome, "outcome must be non-null"); + } + } + + private record SourceRejected() implements SourceAttempt {} + + private record SourceInterrupted() implements SourceAttempt {} + + private record SourceTimedOut() implements SourceAttempt {} + + private record ImmediateResult(CacheResult result) implements SourceAttempt { + + private ImmediateResult { + Objects.requireNonNull(result, "result must be non-null"); + } + } + + private record StaleCandidate( + T value, + String sourceRevision, + Instant hardExpiresAt, + CacheObservationToken observationToken) { + + private StaleCandidate { + Objects.requireNonNull(value, "value must be non-null"); + Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null"); + Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null"); + Objects.requireNonNull(observationToken, "observationToken must be non-null"); + } + } + + private record RefillCondition( + CacheRecordIntent intent, + CacheObservationToken observationToken, + CacheWriteCondition writeCondition) { + + private RefillCondition { + Objects.requireNonNull(intent, "intent must be non-null"); + Objects.requireNonNull(observationToken, "observationToken must be non-null"); + Objects.requireNonNull(writeCondition, "writeCondition must be non-null"); + } + + private static RefillCondition absent(CacheWriteCondition writeCondition) { + return new RefillCondition( + CacheRecordIntent.ONLY_IF_ABSENT, CacheObservationToken.unavailable(), writeCondition); + } + + private static RefillCondition observed( + CacheObservationToken token, CacheWriteCondition writeCondition) { + return new RefillCondition(CacheRecordIntent.ONLY_IF_OBSERVED, token, writeCondition); + } + } + + private record Recheck(RefillCondition refillCondition, CacheResult immediateResult) { + + private static Recheck refill(RefillCondition refillCondition) { + return new Recheck<>( + Objects.requireNonNull(refillCondition, "refillCondition must be non-null"), null); + } + + private static Recheck immediate(CacheResult result) { + return new Recheck<>(null, Objects.requireNonNull(result, "result must be non-null")); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsidePolicy.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsidePolicy.java new file mode 100644 index 0000000..0890b86 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsidePolicy.java @@ -0,0 +1,54 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; +import java.util.Objects; + +/** Immutable per-region bounds for cache-aside fallback and local coalescing. */ +public record CacheAsidePolicy( + int maximumInFlightSourceKeys, + int maximumWaitersPerKey, + int maximumConcurrentSourceLoads, + Duration sourceAdmissionWait, + Duration sourceLoadDeadline, + boolean serveStaleOnTransientFailure) { + + private static final int MAXIMUM_COUNT_BOUND = 4096; + private static final Duration MAXIMUM_DURATION_BOUND = Duration.ofDays(30); + + public CacheAsidePolicy { + requirePositiveBound( + maximumInFlightSourceKeys, "maximumInFlightSourceKeys", MAXIMUM_COUNT_BOUND); + if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_COUNT_BOUND) { + throw new IllegalArgumentException( + "maximumWaitersPerKey must be in 0.." + MAXIMUM_COUNT_BOUND); + } + requirePositiveBound( + maximumConcurrentSourceLoads, "maximumConcurrentSourceLoads", MAXIMUM_COUNT_BOUND); + requireDuration(sourceAdmissionWait, "sourceAdmissionWait", true); + requireDuration(sourceLoadDeadline, "sourceLoadDeadline", false); + } + + Duration maximumWaitDuration() { + return sourceAdmissionWait.plus(sourceLoadDeadline); + } + + private static void requirePositiveBound(int value, String field, int maximum) { + if (value < 1 || value > maximum) { + throw new IllegalArgumentException(field + " must be in 1.." + maximum); + } + } + + private static void requireDuration(Duration value, String field, boolean zeroAllowed) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isNegative() + || (!zeroAllowed && value.isZero()) + || value.compareTo(MAXIMUM_DURATION_BOUND) > 0) { + throw new IllegalArgumentException( + field + + " must be " + + (zeroAllowed ? "non-negative" : "positive") + + " and at most " + + MAXIMUM_DURATION_BOUND); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheCancellationToken.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheCancellationToken.java new file mode 100644 index 0000000..39a461f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheCancellationToken.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.cache; + +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; + +/** + * Cooperative source-load cancellation signal. It observes the executing thread's interrupt flag + * and an immutable deadline; it cannot forcibly stop arbitrary source code. + */ +public final class CacheCancellationToken { + + private final Instant deadline; + private final Clock clock; + + CacheCancellationToken(Instant deadline, Clock clock) { + this.deadline = Objects.requireNonNull(deadline, "deadline must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + public Instant deadline() { + return deadline; + } + + public boolean isDeadlineExceeded() { + return !clock.instant().isBefore(deadline); + } + + public boolean isInterrupted() { + return Thread.currentThread().isInterrupted(); + } + + public boolean isCancellationRequested() { + return isInterrupted() || isDeadlineExceeded(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java index c137516..635e8fb 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java @@ -1,5 +1,6 @@ package dev.caskeleton.application.cache; +import java.time.Instant; import java.util.Objects; /** Lookup result that never collapses provider failure, negative entries, and normal misses. */ @@ -10,7 +11,48 @@ public sealed interface CacheLookup CacheLookup.IncompatibleSchema, CacheLookup.Unavailable { - record Hit(V value, Freshness freshness, String sourceRevision) implements CacheLookup { + record Hit( + V value, + Freshness freshness, + String sourceRevision, + Instant softExpiresAt, + Instant hardExpiresAt, + CacheObservationToken observationToken, + CacheWriteCondition writeCondition) + implements CacheLookup { + + public Hit( + V value, + Freshness freshness, + String sourceRevision, + Instant softExpiresAt, + Instant hardExpiresAt) { + this( + value, + freshness, + sourceRevision, + softExpiresAt, + hardExpiresAt, + CacheObservationToken.unavailable(), + CacheWriteCondition.unavailable()); + } + + public Hit( + V value, + Freshness freshness, + String sourceRevision, + Instant softExpiresAt, + Instant hardExpiresAt, + CacheObservationToken observationToken) { + this( + value, + freshness, + sourceRevision, + softExpiresAt, + hardExpiresAt, + observationToken, + CacheWriteCondition.unavailable()); + } public Hit { Objects.requireNonNull(value, "value must be non-null"); @@ -18,38 +60,74 @@ public sealed interface CacheLookup if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) { throw new IllegalArgumentException("sourceRevision must contain 1..128 characters"); } + Objects.requireNonNull(softExpiresAt, "softExpiresAt must be non-null"); + Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null"); + if (softExpiresAt.isAfter(hardExpiresAt)) { + throw new IllegalArgumentException("softExpiresAt must not be after hardExpiresAt"); + } + Objects.requireNonNull(observationToken, "observationToken must be non-null"); + Objects.requireNonNull(writeCondition, "writeCondition must be non-null"); } } - record NegativeHit(AuthoritativeAbsence reason) implements CacheLookup { + record NegativeHit(AuthoritativeAbsence reason, Instant hardExpiresAt) + implements CacheLookup { public NegativeHit { Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(hardExpiresAt, "hardExpiresAt must be non-null"); } } - record Miss(MissReason reason) implements CacheLookup { + record Miss(MissReason reason, CacheWriteCondition writeCondition) implements CacheLookup { + + public Miss(MissReason reason) { + this(reason, CacheWriteCondition.unavailable()); + } public Miss { Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(writeCondition, "writeCondition must be non-null"); } } - record IncompatibleSchema(SchemaCategory category, SchemaPolicy policy) + record IncompatibleSchema( + SchemaCategory category, + SchemaPolicy policy, + CacheObservationToken observationToken, + CacheWriteCondition writeCondition) implements CacheLookup { + public IncompatibleSchema(SchemaCategory category, SchemaPolicy policy) { + this( + category, policy, CacheObservationToken.unavailable(), CacheWriteCondition.unavailable()); + } + + public IncompatibleSchema( + SchemaCategory category, SchemaPolicy policy, CacheObservationToken observationToken) { + this(category, policy, observationToken, CacheWriteCondition.unavailable()); + } + public IncompatibleSchema { Objects.requireNonNull(category, "category must be non-null"); Objects.requireNonNull(policy, "policy must be non-null"); + Objects.requireNonNull(observationToken, "observationToken must be non-null"); + Objects.requireNonNull(writeCondition, "writeCondition must be non-null"); } } - record Unavailable(UnavailabilityReason reason, OperationCertainty certainty) + record Unavailable( + UnavailabilityReason reason, OperationCertainty certainty, CacheWriteCondition writeCondition) implements CacheLookup { + public Unavailable(UnavailabilityReason reason, OperationCertainty certainty) { + this(reason, certainty, CacheWriteCondition.unavailable()); + } + public Unavailable { Objects.requireNonNull(reason, "reason must be non-null"); Objects.requireNonNull(certainty, "certainty must be non-null"); + Objects.requireNonNull(writeCondition, "writeCondition must be non-null"); } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationEvent.java new file mode 100644 index 0000000..4412731 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationEvent.java @@ -0,0 +1,97 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; +import java.util.Objects; + +/** + * Framework-free, cache-only diagnostic events. + * + *

Events intentionally omit semantic keys, tenant/user identifiers and provider endpoints. + * {@code cacheName} is a bounded code-owned name suitable for a low-cardinality metric tag. + */ +public sealed interface CacheObservationEvent { + + String cacheName(); + + /** One lookup at a concrete cache tier. */ + record Lookup(String cacheName, Tier tier, LookupResult result, Duration entryAge) + implements CacheObservationEvent { + + public Lookup { + cacheName = boundedCacheName(cacheName); + Objects.requireNonNull(tier, "tier must be non-null"); + Objects.requireNonNull(result, "result must be non-null"); + Objects.requireNonNull(entryAge, "entryAge must be non-null"); + if (entryAge.isNegative() || entryAge.compareTo(Duration.ofDays(30)) > 0) { + throw new IllegalArgumentException("entryAge must be between zero and 30 days"); + } + } + } + + /** A bounded local-tier eviction, flush, subscriber or generation reconciliation action. */ + record LocalMaintenance( + String cacheName, + MaintenanceAction action, + MaintenanceResult result, + MaintenanceCause cause, + int affectedEntries) + implements CacheObservationEvent { + + public LocalMaintenance { + cacheName = boundedCacheName(cacheName); + Objects.requireNonNull(action, "action must be non-null"); + Objects.requireNonNull(result, "result must be non-null"); + Objects.requireNonNull(cause, "cause must be non-null"); + if (affectedEntries < 0 || affectedEntries > 1_000_000) { + throw new IllegalArgumentException("affectedEntries must be in 0..1000000"); + } + } + } + + enum Tier { + LOCAL_L1, + REDIS_L2 + } + + enum LookupResult { + HIT, + MISS, + ERROR, + BYPASS + } + + enum MaintenanceAction { + EVICT, + FLUSH, + RECONCILE, + SUBSCRIBER_EVENT + } + + enum MaintenanceResult { + SUCCESS, + FLUSHED, + DROPPED, + ERROR, + UNCHANGED + } + + enum MaintenanceCause { + CARDINALITY, + WEIGHT, + TTL, + INVALIDATION, + GENERATION_CHANGED, + SUBSCRIBER_DISCONNECTED, + SUBSCRIBER_OVERFLOW, + MALFORMED_MESSAGE, + RECONCILIATION_FAILURE + } + + private static String boundedCacheName(String value) { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException( + "cacheName must be a code-owned lower-case slug with 1..63 characters"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationPort.java new file mode 100644 index 0000000..17d8228 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.cache; + +/** Framework-free output boundary for low-cardinality cache diagnostics. */ +@FunctionalInterface +public interface CacheObservationPort { + + void observe(CacheObservationEvent event); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationToken.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationToken.java new file mode 100644 index 0000000..591ca29 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheObservationToken.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.cache; + +/** + * Opaque provider observation used only for conditional cache replacement. Application code must + * not parse or manufacture it. + */ +public record CacheObservationToken(String value) { + + private static final String UNAVAILABLE_VALUE = "observation-unavailable"; + + public CacheObservationToken { + if (value == null || !value.matches("[A-Za-z0-9_-]{16,128}")) { + throw new IllegalArgumentException( + "cache observation token must have a bounded opaque representation"); + } + } + + public static CacheObservationToken unavailable() { + return new CacheObservationToken(UNAVAILABLE_VALUE); + } + + public boolean usable() { + return !UNAVAILABLE_VALUE.equals(value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java index 68b699d..d551f18 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordIntent.java @@ -3,5 +3,7 @@ package dev.caskeleton.application.cache; /** Application-visible consistency intent; technical TTL and codec remain provider policy. */ public enum CacheRecordIntent { UPSERT, + ONLY_IF_ABSENT, + ONLY_IF_OBSERVED, ONLY_IF_SOURCE_REVISION_NEWER } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java index 4fc1053..2acdb46 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java @@ -2,13 +2,40 @@ package dev.caskeleton.application.cache; import java.util.Objects; -/** Metadata derived from the authoritative source, never from a cache provider. */ -public record CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) { +/** Source revision plus an optional opaque condition captured by the preceding cache lookup. */ +public record CacheRecordMetadata( + String sourceRevision, + CacheRecordIntent intent, + CacheObservationToken observedToken, + CacheWriteCondition writeCondition) { + + public CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) { + this( + sourceRevision, + intent, + CacheObservationToken.unavailable(), + CacheWriteCondition.unavailable()); + } + + public CacheRecordMetadata( + String sourceRevision, CacheRecordIntent intent, CacheObservationToken observedToken) { + this(sourceRevision, intent, observedToken, CacheWriteCondition.unavailable()); + } public CacheRecordMetadata { if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) { throw new IllegalArgumentException("sourceRevision must contain 1..128 characters"); } Objects.requireNonNull(intent, "intent must be non-null"); + Objects.requireNonNull(observedToken, "observedToken must be non-null"); + Objects.requireNonNull(writeCondition, "writeCondition must be non-null"); + if (intent == CacheRecordIntent.ONLY_IF_OBSERVED && !observedToken.usable()) { + throw new IllegalArgumentException( + "ONLY_IF_OBSERVED requires a usable cache observation token"); + } + if (intent != CacheRecordIntent.ONLY_IF_OBSERVED && observedToken.usable()) { + throw new IllegalArgumentException( + "a cache observation token is valid only for ONLY_IF_OBSERVED"); + } } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimAttempt.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimAttempt.java new file mode 100644 index 0000000..a8dcf7d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimAttempt.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Owner plus operation identities that must be reused for an uncertain claim retry. */ +public record CacheRefreshClaimAttempt( + CacheRefreshOwnerToken ownerToken, CacheRefreshOperationToken operationToken) { + + private static final CacheRefreshClaimAttempt UNAVAILABLE = + new CacheRefreshClaimAttempt( + CacheRefreshOwnerToken.unavailable(), CacheRefreshOperationToken.unavailable()); + + public CacheRefreshClaimAttempt { + Objects.requireNonNull(ownerToken, "ownerToken must be non-null"); + Objects.requireNonNull(operationToken, "operationToken must be non-null"); + if (ownerToken.usable() != operationToken.usable()) { + throw new IllegalArgumentException("refresh claim attempt tokens must have equal usability"); + } + } + + public static CacheRefreshClaimAttempt unavailable() { + return UNAVAILABLE; + } + + public boolean usable() { + return ownerToken.usable(); + } + + @Override + public String toString() { + return usable() + ? "CacheRefreshClaimAttempt[redacted]" + : "CacheRefreshClaimAttempt[unavailable]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimOutcome.java new file mode 100644 index 0000000..f38602e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshClaimOutcome.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Provider-neutral result of attempting to own a cache-refresh soft lease. */ +public sealed interface CacheRefreshClaimOutcome + permits CacheRefreshClaimOutcome.Claimed, + CacheRefreshClaimOutcome.AlreadyOwned, + CacheRefreshClaimOutcome.Contended, + CacheRefreshClaimOutcome.Disabled, + CacheRefreshClaimOutcome.Unavailable, + CacheRefreshClaimOutcome.Indeterminate { + + record Claimed(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome { + + public Claimed { + requireUsable(attempt); + } + } + + record AlreadyOwned(CacheRefreshClaimAttempt attempt) implements CacheRefreshClaimOutcome { + + public AlreadyOwned { + requireUsable(attempt); + } + } + + record Contended() implements CacheRefreshClaimOutcome {} + + record Disabled() implements CacheRefreshClaimOutcome {} + + record Unavailable() implements CacheRefreshClaimOutcome {} + + record Indeterminate() implements CacheRefreshClaimOutcome {} + + private static void requireUsable(CacheRefreshClaimAttempt attempt) { + Objects.requireNonNull(attempt, "attempt must be non-null"); + if (!attempt.usable()) { + throw new IllegalArgumentException("owned refresh claim requires a usable attempt"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPolicy.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPolicy.java new file mode 100644 index 0000000..33e40f8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPolicy.java @@ -0,0 +1,47 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; +import java.util.Objects; + +/** Finite soft-lease and hard-miss behavior for optional distributed refresh coordination. */ +public record CacheRefreshCoordinationPolicy( + Duration leaseTimeToLive, HardMissPolicy hardMissPolicy, Duration hardMissWait) { + + private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(5); + private static final Duration MAXIMUM_HARD_MISS_WAIT = Duration.ofSeconds(5); + + public CacheRefreshCoordinationPolicy { + Objects.requireNonNull(leaseTimeToLive, "leaseTimeToLive must be non-null"); + Objects.requireNonNull(hardMissPolicy, "hardMissPolicy must be non-null"); + Objects.requireNonNull(hardMissWait, "hardMissWait must be non-null"); + if (leaseTimeToLive.isZero() + || leaseTimeToLive.isNegative() + || leaseTimeToLive.compareTo(MAXIMUM_LEASE) > 0) { + throw new IllegalArgumentException( + "refresh lease TTL must be positive and at most 5 minutes"); + } + if (hardMissWait.isNegative() || hardMissWait.compareTo(MAXIMUM_HARD_MISS_WAIT) > 0) { + throw new IllegalArgumentException("hard miss wait must be between zero and 5 seconds"); + } + if (hardMissPolicy == HardMissPolicy.NORMAL_SOURCE_LOAD && !hardMissWait.isZero()) { + throw new IllegalArgumentException("normal hard miss source load requires zero wait"); + } + if (hardMissPolicy == HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD && hardMissWait.isZero()) { + throw new IllegalArgumentException("bounded hard miss wait must be positive"); + } + } + + public CacheRefreshCoordinationPolicy validateAgainst(CacheAsidePolicy cacheAsidePolicy) { + Objects.requireNonNull(cacheAsidePolicy, "cacheAsidePolicy must be non-null"); + if (leaseTimeToLive.compareTo(cacheAsidePolicy.sourceLoadDeadline()) <= 0) { + throw new IllegalArgumentException( + "refresh lease TTL must be longer than the source load deadline"); + } + return this; + } + + public enum HardMissPolicy { + NORMAL_SOURCE_LOAD, + BOUNDED_WAIT_THEN_SOURCE_LOAD + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java new file mode 100644 index 0000000..0c3b9a5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; + +/** + * Optional soft-lease coordination for cache refresh admission. + * + *

This port reduces duplicate refresh work. It is not a correctness lock and must not protect + * domain invariants. The claim owner performs its source refresh synchronously; only a stale + * contender or a stale request facing coordination failure returns immediately with a deferral + * result. + */ +public interface CacheRefreshCoordinationPort { + + default boolean enabled() { + return true; + } + + CacheRefreshClaimAttempt newAttempt(); + + CacheRefreshClaimOutcome claim(K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive); + + CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOperationToken.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOperationToken.java new file mode 100644 index 0000000..970f0d0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOperationToken.java @@ -0,0 +1,62 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Opaque idempotency identity reused when a refresh-claim response is uncertain. */ +public final class CacheRefreshOperationToken { + + private static final CacheRefreshOperationToken UNAVAILABLE = new CacheRefreshOperationToken(); + + private final String value; + + public CacheRefreshOperationToken(String value) { + this.value = validate(value); + } + + private CacheRefreshOperationToken() { + value = null; + } + + public static CacheRefreshOperationToken unavailable() { + return UNAVAILABLE; + } + + public boolean usable() { + return value != null; + } + + public String value() { + if (!usable()) { + throw new IllegalStateException("cache refresh operation token is unavailable"); + } + return value; + } + + @Override + public boolean equals(Object candidate) { + return candidate instanceof CacheRefreshOperationToken other + && Objects.equals(value, other.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public String toString() { + return usable() + ? "CacheRefreshOperationToken[redacted]" + : "CacheRefreshOperationToken[unavailable]"; + } + + private static String validate(String value) { + if (value == null + || !value.matches("[A-Za-z0-9_-]{16,128}") + || value.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException( + "operation token must contain 16..128 URL-safe non-control characters"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOwnerToken.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOwnerToken.java new file mode 100644 index 0000000..da41c75 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshOwnerToken.java @@ -0,0 +1,59 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Opaque owner identity for one bounded cache-refresh soft lease. */ +public final class CacheRefreshOwnerToken { + + private static final CacheRefreshOwnerToken UNAVAILABLE = new CacheRefreshOwnerToken(); + + private final String value; + + public CacheRefreshOwnerToken(String value) { + this.value = validate(value, "owner token"); + } + + private CacheRefreshOwnerToken() { + value = null; + } + + public static CacheRefreshOwnerToken unavailable() { + return UNAVAILABLE; + } + + public boolean usable() { + return value != null; + } + + public String value() { + if (!usable()) { + throw new IllegalStateException("cache refresh owner token is unavailable"); + } + return value; + } + + @Override + public boolean equals(Object candidate) { + return candidate instanceof CacheRefreshOwnerToken other && Objects.equals(value, other.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public String toString() { + return usable() ? "CacheRefreshOwnerToken[redacted]" : "CacheRefreshOwnerToken[unavailable]"; + } + + private static String validate(String value, String field) { + if (value == null + || !value.matches("[A-Za-z0-9_-]{16,128}") + || value.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException( + field + " must contain 16..128 URL-safe non-control characters"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshReleaseOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshReleaseOutcome.java new file mode 100644 index 0000000..6f02ae6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshReleaseOutcome.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.cache; + +/** Provider-neutral owner-safe release result for a cache-refresh soft lease. */ +public sealed interface CacheRefreshReleaseOutcome + permits CacheRefreshReleaseOutcome.Released, + CacheRefreshReleaseOutcome.AlreadyReleased, + CacheRefreshReleaseOutcome.NotOwner, + CacheRefreshReleaseOutcome.Disabled, + CacheRefreshReleaseOutcome.Unavailable, + CacheRefreshReleaseOutcome.Indeterminate { + + record Released() implements CacheRefreshReleaseOutcome {} + + record AlreadyReleased() implements CacheRefreshReleaseOutcome {} + + record NotOwner() implements CacheRefreshReleaseOutcome {} + + record Disabled() implements CacheRefreshReleaseOutcome {} + + record Unavailable() implements CacheRefreshReleaseOutcome {} + + record Indeterminate() implements CacheRefreshReleaseOutcome {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java index 3194c07..46a28bf 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java @@ -13,4 +13,11 @@ public interface CacheRegionPort { CacheRecordOutcome recordAbsent(K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata); CacheInvalidationOutcome invalidate(K key); + + /** + * Makes every entry written under the previously captured region generation invisible. + * + *

This is a semantic mass invalidation, not a provider key scan or bulk delete. + */ + CacheInvalidationOutcome invalidateRegion(); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheResult.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheResult.java new file mode 100644 index 0000000..5258a7c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheResult.java @@ -0,0 +1,131 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** End-to-end cache-aside result without provider or transport types. */ +public sealed interface CacheResult + permits CacheResult.FreshHit, + CacheResult.NegativeHit, + CacheResult.LoadedFromSource, + CacheResult.AuthoritativeAbsent, + CacheResult.StaleFallbackAfterTransientFailure, + CacheResult.StaleRefreshDeferred, + CacheResult.SourceFailed, + CacheResult.Rejected, + CacheResult.Cancelled, + CacheResult.IncompatibleSchema { + + record FreshHit(V value, String sourceRevision) implements CacheResult { + + public FreshHit { + Objects.requireNonNull(value, "value must be non-null"); + requireSourceRevision(sourceRevision); + } + } + + record NegativeHit(dev.caskeleton.application.cache.AuthoritativeAbsence reason) + implements CacheResult { + + public NegativeHit { + Objects.requireNonNull(reason, "reason must be non-null"); + } + } + + record LoadedFromSource(V value, String sourceRevision, CacheRecordOutcome recordOutcome) + implements CacheResult { + + public LoadedFromSource { + Objects.requireNonNull(value, "value must be non-null"); + requireSourceRevision(sourceRevision); + Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null"); + } + } + + record AuthoritativeAbsent( + dev.caskeleton.application.cache.AuthoritativeAbsence reason, + String sourceRevision, + CacheRecordOutcome recordOutcome) + implements CacheResult { + + public AuthoritativeAbsent { + Objects.requireNonNull(reason, "reason must be non-null"); + requireSourceRevision(sourceRevision); + Objects.requireNonNull(recordOutcome, "recordOutcome must be non-null"); + } + } + + record StaleFallbackAfterTransientFailure( + V value, String sourceRevision, SourceFailure failure) implements CacheResult { + + public StaleFallbackAfterTransientFailure { + Objects.requireNonNull(value, "value must be non-null"); + requireSourceRevision(sourceRevision); + Objects.requireNonNull(failure, "failure must be non-null"); + } + } + + record StaleRefreshDeferred(V value, String sourceRevision, RefreshDeferralReason reason) + implements CacheResult { + + public StaleRefreshDeferred { + Objects.requireNonNull(value, "value must be non-null"); + requireSourceRevision(sourceRevision); + Objects.requireNonNull(reason, "reason must be non-null"); + } + } + + record SourceFailed(SourceFailure failure, SourceFailureKind kind) implements CacheResult { + + public SourceFailed { + Objects.requireNonNull(failure, "failure must be non-null"); + Objects.requireNonNull(kind, "kind must be non-null"); + } + } + + record Rejected(RejectionReason reason) implements CacheResult { + + public Rejected { + Objects.requireNonNull(reason, "reason must be non-null"); + } + } + + record Cancelled() implements CacheResult {} + + record IncompatibleSchema(CacheLookup.SchemaCategory category, CacheLookup.SchemaPolicy policy) + implements CacheResult { + + public IncompatibleSchema { + Objects.requireNonNull(category, "category must be non-null"); + Objects.requireNonNull(policy, "policy must be non-null"); + } + } + + enum SourceFailureKind { + TRANSIENT, + PERMANENT + } + + enum RejectionReason { + MAXIMUM_IN_FLIGHT_KEYS, + MAXIMUM_WAITERS, + SOURCE_OVERLOADED, + WAIT_TIMEOUT, + LOAD_TIMEOUT + } + + enum RefreshDeferralReason { + CONTENDED, + COORDINATION_UNAVAILABLE, + COORDINATION_INDETERMINATE + } + + private static void requireSourceRevision(String sourceRevision) { + if (sourceRevision == null + || sourceRevision.isBlank() + || sourceRevision.length() > 128 + || sourceRevision.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException( + "sourceRevision must contain 1..128 non-control characters"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java new file mode 100644 index 0000000..18b34b9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java @@ -0,0 +1,210 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** Bounded, synchronous local coalescing for one semantic cache region. */ +public final class CacheSingleFlight { + + private static final int MAXIMUM_BOUND = 4096; + + private final int maximumInFlightKeys; + private final int maximumWaitersPerKey; + private final LongSupplier monotonicTicker; + private final Map> flights = new HashMap<>(); + + public CacheSingleFlight(int maximumInFlightKeys, int maximumWaitersPerKey) { + this(maximumInFlightKeys, maximumWaitersPerKey, System::nanoTime); + } + + CacheSingleFlight( + int maximumInFlightKeys, int maximumWaitersPerKey, LongSupplier monotonicTicker) { + if (maximumInFlightKeys < 1 || maximumInFlightKeys > MAXIMUM_BOUND) { + throw new IllegalArgumentException("maximumInFlightKeys must be in 1.." + MAXIMUM_BOUND); + } + if (maximumWaitersPerKey < 0 || maximumWaitersPerKey > MAXIMUM_BOUND) { + throw new IllegalArgumentException("maximumWaitersPerKey must be in 0.." + MAXIMUM_BOUND); + } + this.maximumInFlightKeys = maximumInFlightKeys; + this.maximumWaitersPerKey = maximumWaitersPerKey; + this.monotonicTicker = + Objects.requireNonNull(monotonicTicker, "monotonicTicker must be non-null"); + } + + public Outcome execute(K key, Duration waiterTimeout, Supplier leaderAction) { + Objects.requireNonNull(key, "key must be non-null"); + Objects.requireNonNull(waiterTimeout, "waiterTimeout must be non-null"); + Objects.requireNonNull(leaderAction, "leaderAction must be non-null"); + if (waiterTimeout.isNegative()) { + throw new IllegalArgumentException("waiterTimeout must be non-negative"); + } + + Flight flight; + boolean leader; + synchronized (flights) { + long monotonicNow = monotonicTicker.getAsLong(); + flight = flights.get(key); + if (flight != null && flight.isAbandonedAt(monotonicNow)) { + flights.remove(key, flight); + flight = null; + } + if (flight == null) { + if (flights.size() >= maximumInFlightKeys) { + flights.values().removeIf(candidate -> candidate.isAbandonedAt(monotonicNow)); + if (flights.size() >= maximumInFlightKeys) { + return new Rejected<>(RejectionReason.MAXIMUM_IN_FLIGHT_KEYS); + } + } + flight = new Flight<>(deadlineFrom(monotonicNow, waiterTimeout)); + flights.put(key, flight); + leader = true; + } else { + leader = false; + } + } + if (!leader) { + return await(flight, waiterTimeout); + } + + try { + V value = Objects.requireNonNull(leaderAction.get(), "leader result must be non-null"); + flight.result.complete(value); + return new Completed<>(value); + } catch (RuntimeException | Error failure) { + flight.result.completeExceptionally(failure); + throw failure; + } finally { + synchronized (flights) { + flights.remove(key, flight); + } + } + } + + int inFlightCount() { + synchronized (flights) { + return flights.size(); + } + } + + int waiterCount(K key) { + synchronized (flights) { + Flight flight = flights.get(key); + return flight == null ? 0 : flight.waiters.get(); + } + } + + private Outcome await(Flight flight, Duration waiterTimeout) { + if (Thread.currentThread().isInterrupted()) { + return new Interrupted<>(); + } + if (!acquireWaiter(flight)) { + return new Rejected<>(RejectionReason.MAXIMUM_WAITERS); + } + try { + return new Completed<>( + flight.result.get(toNanosSaturated(waiterTimeout), TimeUnit.NANOSECONDS)); + } catch (TimeoutException timeout) { + return new Rejected<>(RejectionReason.WAIT_TIMEOUT); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return new Interrupted<>(); + } catch (ExecutionException execution) { + return rethrow(execution.getCause()); + } finally { + flight.waiters.decrementAndGet(); + } + } + + private boolean acquireWaiter(Flight flight) { + while (true) { + int current = flight.waiters.get(); + if (current >= maximumWaitersPerKey) { + return false; + } + if (flight.waiters.compareAndSet(current, current + 1)) { + return true; + } + } + } + + private static long toNanosSaturated(Duration duration) { + try { + return duration.toNanos(); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + + private static long deadlineFrom(long monotonicNow, Duration timeout) { + long timeoutNanos = toNanosSaturated(timeout); + if (timeoutNanos == Long.MAX_VALUE) { + return Long.MAX_VALUE; + } + try { + return Math.addExact(monotonicNow, timeoutNanos); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + + private static Outcome rethrow(Throwable cause) { + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException( + "single-flight completed with an unexpected checked failure", cause); + } + + public sealed interface Outcome permits Completed, Rejected, Interrupted {} + + public record Completed(T value) implements Outcome { + + public Completed { + Objects.requireNonNull(value, "value must be non-null"); + } + } + + public record Rejected(RejectionReason reason) implements Outcome { + + public Rejected { + Objects.requireNonNull(reason, "reason must be non-null"); + } + } + + public record Interrupted() implements Outcome {} + + public enum RejectionReason { + MAXIMUM_IN_FLIGHT_KEYS, + MAXIMUM_WAITERS, + WAIT_TIMEOUT + } + + private static final class Flight { + + private final CompletableFuture result = new CompletableFuture<>(); + private final AtomicInteger waiters = new AtomicInteger(); + private final long abandonAtNanos; + + private Flight(long abandonAtNanos) { + this.abandonAtNanos = abandonAtNanos; + } + + private boolean isAbandonedAt(long monotonicNow) { + return abandonAtNanos != Long.MAX_VALUE + && monotonicNow - abandonAtNanos >= 0 + && !result.isDone(); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceBulkhead.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceBulkhead.java new file mode 100644 index 0000000..39454db --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceBulkhead.java @@ -0,0 +1,69 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** Bounded source concurrency admission shared by every key in one semantic cache region. */ +public final class CacheSourceBulkhead { + + private final Semaphore permits; + + public CacheSourceBulkhead(int maximumConcurrentLoads) { + if (maximumConcurrentLoads < 1) { + throw new IllegalArgumentException("maximumConcurrentLoads must be positive"); + } + permits = new Semaphore(maximumConcurrentLoads, true); + } + + public Outcome execute(Duration admissionWait, Supplier action) { + Objects.requireNonNull(admissionWait, "admissionWait must be non-null"); + Objects.requireNonNull(action, "action must be non-null"); + if (admissionWait.isNegative()) { + throw new IllegalArgumentException("admissionWait must be non-negative"); + } + if (Thread.currentThread().isInterrupted()) { + return new Interrupted<>(); + } + + boolean acquired; + try { + acquired = permits.tryAcquire(toNanosSaturated(admissionWait), TimeUnit.NANOSECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return new Interrupted<>(); + } + if (!acquired) { + return new Rejected<>(); + } + try { + return new Completed<>( + Objects.requireNonNull(action.get(), "source action result must be non-null")); + } finally { + permits.release(); + } + } + + private static long toNanosSaturated(Duration duration) { + try { + return duration.toNanos(); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + + public sealed interface Outcome permits Completed, Rejected, Interrupted {} + + public record Completed(T value) implements Outcome { + + public Completed { + Objects.requireNonNull(value, "value must be non-null"); + } + } + + public record Rejected() implements Outcome {} + + public record Interrupted() implements Outcome {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceLoader.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceLoader.java new file mode 100644 index 0000000..75824cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheSourceLoader.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.cache; + +/** Loads one semantic cache key from its authoritative source. */ +@FunctionalInterface +public interface CacheSourceLoader { + + SourceLoadOutcome load(K key, CacheCancellationToken cancellation); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java new file mode 100644 index 0000000..c6812f6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.cache; + +import java.nio.charset.StandardCharsets; + +/** + * Opaque provider snapshot captured by lookup and returned unchanged when recording a source load. + * + *

The application coordinates the token but never parses provider generation or revision + * details. + */ +public record CacheWriteCondition(String value) { + + private static final String UNAVAILABLE_VALUE = "write-condition-unavailable"; + private static final int MAXIMUM_BYTES = 512; + + public CacheWriteCondition { + if (value == null + || value.isBlank() + || value.getBytes(StandardCharsets.UTF_8).length > MAXIMUM_BYTES) { + throw new IllegalArgumentException( + "cache write condition must contain a bounded opaque value of 1..512 UTF-8 bytes"); + } + } + + public static CacheWriteCondition unavailable() { + return new CacheWriteCondition(UNAVAILABLE_VALUE); + } + + public boolean usable() { + return !UNAVAILABLE_VALUE.equals(value); + } + + @Override + public String toString() { + return "CacheWriteCondition[REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheObservationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheObservationPort.java new file mode 100644 index 0000000..a3a2acc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheObservationPort.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.cache; + +/** Explicit no-op cache diagnostics adapter used when no metrics backend is installed. */ +public final class DisabledCacheObservationPort implements CacheObservationPort { + + private static final DisabledCacheObservationPort INSTANCE = new DisabledCacheObservationPort(); + + private DisabledCacheObservationPort() {} + + public static DisabledCacheObservationPort instance() { + return INSTANCE; + } + + @Override + public void observe(CacheObservationEvent event) { + // Diagnostics must never change cache semantics. + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheRefreshCoordinationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheRefreshCoordinationPort.java new file mode 100644 index 0000000..726f814 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/DisabledCacheRefreshCoordinationPort.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.cache; + +import java.time.Duration; + +/** Explicit no-op refresh coordinator for deployments that disable distributed soft leases. */ +public final class DisabledCacheRefreshCoordinationPort + implements CacheRefreshCoordinationPort { + + private static final DisabledCacheRefreshCoordinationPort INSTANCE = + new DisabledCacheRefreshCoordinationPort<>(); + + private DisabledCacheRefreshCoordinationPort() {} + + @SuppressWarnings("unchecked") + public static DisabledCacheRefreshCoordinationPort instance() { + return (DisabledCacheRefreshCoordinationPort) INSTANCE; + } + + @Override + public boolean enabled() { + return false; + } + + @Override + public CacheRefreshClaimAttempt newAttempt() { + return CacheRefreshClaimAttempt.unavailable(); + } + + @Override + public CacheRefreshClaimOutcome claim( + K key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + return new CacheRefreshClaimOutcome.Disabled(); + } + + @Override + public CacheRefreshReleaseOutcome release(K key, CacheRefreshClaimAttempt attempt) { + return new CacheRefreshReleaseOutcome.Disabled(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/SourceFailure.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/SourceFailure.java new file mode 100644 index 0000000..5dbee33 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/SourceFailure.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** + * Bounded source failure classification with its original cause preserved for the application + * boundary. The cause message must not be copied into cache state, metrics, or tags. + */ +public record SourceFailure(String code, Throwable cause) { + + public SourceFailure { + if (code == null || !code.matches("[A-Z][A-Z0-9_]{0,63}")) { + throw new IllegalArgumentException("code must be a bounded uppercase failure code"); + } + Objects.requireNonNull(cause, "cause must be non-null"); + } + + @Override + public String toString() { + return "SourceFailure[code=" + code + ']'; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/cache/SourceLoadOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/cache/SourceLoadOutcome.java new file mode 100644 index 0000000..82f27a1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/cache/SourceLoadOutcome.java @@ -0,0 +1,56 @@ +package dev.caskeleton.application.cache; + +import java.util.Objects; + +/** Business-classified result of consulting a cache region's authoritative source. */ +public sealed interface SourceLoadOutcome + permits SourceLoadOutcome.Loaded, + SourceLoadOutcome.AuthoritativeAbsent, + SourceLoadOutcome.TransientFailure, + SourceLoadOutcome.PermanentFailure, + SourceLoadOutcome.Cancelled { + + record Loaded(V value, String sourceRevision) implements SourceLoadOutcome { + + public Loaded { + Objects.requireNonNull(value, "value must be non-null"); + requireSourceRevision(sourceRevision); + } + } + + record AuthoritativeAbsent( + dev.caskeleton.application.cache.AuthoritativeAbsence reason, String sourceRevision) + implements SourceLoadOutcome { + + public AuthoritativeAbsent { + Objects.requireNonNull(reason, "reason must be non-null"); + requireSourceRevision(sourceRevision); + } + } + + record TransientFailure(SourceFailure failure) implements SourceLoadOutcome { + + public TransientFailure { + Objects.requireNonNull(failure, "failure must be non-null"); + } + } + + record PermanentFailure(SourceFailure failure) implements SourceLoadOutcome { + + public PermanentFailure { + Objects.requireNonNull(failure, "failure must be non-null"); + } + } + + record Cancelled() implements SourceLoadOutcome {} + + private static void requireSourceRevision(String sourceRevision) { + if (sourceRevision == null + || sourceRevision.isBlank() + || sourceRevision.length() > 128 + || sourceRevision.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException( + "sourceRevision must contain 1..128 non-control characters"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java index 944ecf3..d9f63f6 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java @@ -47,6 +47,7 @@ public record FilePublishReceipt( public enum DurabilityGuarantee { PROCESS_LOCAL_SYNC, + FILE_AND_DIRECTORY_SYNC, PROVIDER_ACK_ONLY } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimAttempt.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimAttempt.java new file mode 100644 index 0000000..b05bff8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimAttempt.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.idempotency; + +/** Caller-retained owner and operation tokens allocated before the first provider send. */ +public record IdempotencyClaimAttempt(String ownerToken, String operationId) { + + public IdempotencyClaimAttempt { + ownerToken = IdempotencyV2Validation.opaqueToken(ownerToken, "ownerToken"); + operationId = IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "IdempotencyClaimAttempt[REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimOutcome.java new file mode 100644 index 0000000..836a78f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimOutcome.java @@ -0,0 +1,85 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** Typed result of the atomic request-replay claim state machine. */ +public sealed interface IdempotencyClaimOutcome { + + record Acquired(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyClaimOutcome { + + public Acquired { + Objects.requireNonNull(owner, "owner must be non-null"); + IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record ReplayedAcquire(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyClaimOutcome { + + public ReplayedAcquire { + Objects.requireNonNull(owner, "owner must be non-null"); + IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record TakenOverClaimed(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyClaimOutcome { + + public TakenOverClaimed { + Objects.requireNonNull(owner, "owner must be non-null"); + IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record CompletedReplay(StoredResponse response, Instant replayUntil) + implements IdempotencyClaimOutcome { + + public CompletedReplay { + Objects.requireNonNull(response, "response must be non-null"); + IdempotencyV2Validation.instant(replayUntil, "replayUntil"); + } + + @Override + public String toString() { + return "CompletedReplay[response=REDACTED, replayUntil=" + replayUntil + "]"; + } + } + + record InProgress(Duration retryAfter, long currentAttempt) implements IdempotencyClaimOutcome { + + public InProgress { + retryAfter = + IdempotencyV2Validation.positiveBounded( + retryAfter, IdempotencyV2Validation.MAXIMUM_RETRY_AFTER, "retryAfter"); + currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt"); + } + } + + record RecoveryRequired(long currentAttempt) implements IdempotencyClaimOutcome { + + public RecoveryRequired { + currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt"); + } + } + + record FingerprintMismatch() implements IdempotencyClaimOutcome {} + + record OwnerOperationConflict() implements IdempotencyClaimOutcome {} + + record Indeterminate(String operationId) implements IdempotencyClaimOutcome { + + public Indeterminate { + operationId = IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "Indeterminate[operationId=REDACTED]"; + } + } + + record Unavailable() implements IdempotencyClaimOutcome {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimRequest.java new file mode 100644 index 0000000..eee3d87 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyClaimRequest.java @@ -0,0 +1,60 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; +import java.util.Objects; + +/** + * Atomic claim inputs with separate processing lease and durable recovery/replay retention. + * + *

{@code replayTtl} retains in-progress execution evidence as well as a completed response. It + * must outlive the processing lease so an expired {@code EXECUTING} record becomes recovery + * required instead of disappearing and being unsafely re-executed. + */ +public record IdempotencyClaimRequest( + IdempotencyScope scope, + RequestFingerprint fingerprint, + IdempotencyClaimAttempt claimAttempt, + Duration processingLeaseTtl, + Duration replayTtl, + String responseCodecId, + String policyRevision) { + + public IdempotencyClaimRequest { + Objects.requireNonNull(scope, "scope must be non-null"); + Objects.requireNonNull(fingerprint, "fingerprint must be non-null"); + Objects.requireNonNull(claimAttempt, "claimAttempt must be non-null"); + processingLeaseTtl = + IdempotencyV2Validation.positiveBounded( + processingLeaseTtl, + IdempotencyV2Validation.MAXIMUM_PROCESSING_LEASE, + "processingLeaseTtl"); + replayTtl = + IdempotencyV2Validation.positiveBounded( + replayTtl, IdempotencyV2Validation.MAXIMUM_REPLAY_TTL, "replayTtl"); + if (replayTtl.compareTo(processingLeaseTtl) <= 0) { + throw new IllegalArgumentException( + "replayTtl recovery retention must outlive processingLeaseTtl"); + } + responseCodecId = IdempotencyV2Validation.boundedId(responseCodecId, "responseCodecId"); + policyRevision = IdempotencyV2Validation.boundedId(policyRevision, "policyRevision"); + } + + /** Retention used for in-progress recovery evidence before the record becomes replayable. */ + public Duration recoveryRetention() { + return replayTtl; + } + + @Override + public String toString() { + return "IdempotencyClaimRequest[scope=REDACTED, fingerprint=REDACTED, " + + "claimAttempt=REDACTED, processingLeaseTtl=" + + processingLeaseTtl + + ", replayTtl=" + + replayTtl + + ", responseCodecId=" + + responseCodecId + + ", policyRevision=" + + policyRevision + + "]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyCompleteOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyCompleteOutcome.java new file mode 100644 index 0000000..441555f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyCompleteOutcome.java @@ -0,0 +1,55 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Owner-safe response completion result with same-result replay and conflict separation. */ +public record IdempotencyCompleteOutcome(Status status, String operationId) { + + public IdempotencyCompleteOutcome { + Objects.requireNonNull(status, "status must be non-null"); + operationId = validateOperation(status, operationId); + } + + public static IdempotencyCompleteOutcome responseConflict() { + return new IdempotencyCompleteOutcome(Status.RESPONSE_CONFLICT, null); + } + + public static IdempotencyCompleteOutcome operationConflict() { + return new IdempotencyCompleteOutcome(Status.OPERATION_CONFLICT, null); + } + + public static IdempotencyCompleteOutcome indeterminate(String operationId) { + return new IdempotencyCompleteOutcome(Status.INDETERMINATE, operationId); + } + + public static IdempotencyCompleteOutcome unavailable() { + return new IdempotencyCompleteOutcome(Status.UNAVAILABLE, null); + } + + @Override + public String toString() { + return "IdempotencyCompleteOutcome[status=" + status + ", operationId=REDACTED]"; + } + + private static String validateOperation(Status status, String operationId) { + if (status == Status.INDETERMINATE) { + return IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + if (operationId != null) { + throw new IllegalArgumentException("operationId is valid only for INDETERMINATE"); + } + return null; + } + + public enum Status { + COMPLETED, + ALREADY_COMPLETED_SAME_RESULT, + RESPONSE_CONFLICT, + ABSENT, + NOT_OWNER, + NOT_IN_PROGRESS, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2.java new file mode 100644 index 0000000..db6a627 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2.java @@ -0,0 +1,230 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; +import java.util.Objects; + +/** + * Owner-safe request-replay lifecycle. + * + *

The action runs only after a confirmed {@code STARTED}. This orchestration preserves + * request-replay evidence but does not create a cross-store exactly-once boundary. + */ +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 String policyRevision; + + public IdempotencyExecutorV2( + IdempotencyStorePortV2 store, + Duration processingLeaseTtl, + Duration replayTtl, + Duration failureRetention, + String responseCodecId, + String policyRevision) { + this.store = Objects.requireNonNull(store, "store must be non-null"); + this.processingLeaseTtl = Objects.requireNonNull(processingLeaseTtl); + this.replayTtl = Objects.requireNonNull(replayTtl); + this.failureRetention = Objects.requireNonNull(failureRetention); + this.responseCodecId = Objects.requireNonNull(responseCodecId); + this.policyRevision = Objects.requireNonNull(policyRevision); + new IdempotencyClaimRequest( + IdempotencyScope.of("validation", "validation", "validation"), + new RequestFingerprint("0".repeat(64)), + new IdempotencyClaimAttempt("validation_owner", "validation_operation"), + processingLeaseTtl, + replayTtl, + responseCodecId, + policyRevision); + IdempotencyV2Validation.positiveBounded( + failureRetention, IdempotencyV2Validation.MAXIMUM_REPLAY_TTL, "failureRetention"); + } + + public IdempotencyClaimAttempt newAttempt(String operationId) { + return store.newClaimAttempt(operationId); + } + + public R execute( + IdempotencyScope 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); + IdempotencyClaimOutcome claim = store.claim(request); + if (claim instanceof IdempotencyClaimOutcome.CompletedReplay replay) { + return codec.deserialize(replay.response().payload()); + } + if (claim instanceof IdempotencyClaimOutcome.FingerprintMismatch) { + throw new IdempotencyRequestMismatchException(scope); + } + if (claim instanceof IdempotencyClaimOutcome.InProgress) { + throw new IdempotencyInFlightException(scope); + } + if (claim instanceof IdempotencyClaimOutcome.RecoveryRequired + || claim instanceof IdempotencyClaimOutcome.OwnerOperationConflict) { + throw recovery("claim requires reconciliation"); + } + if (claim instanceof IdempotencyClaimOutcome.Unavailable) { + throw new IdempotencyUnavailableException(); + } + if (claim instanceof IdempotencyClaimOutcome.Indeterminate) { + return reconcileClaim(request, action, codec); + } + IdempotencyOwner owner = + switch (claim) { + case IdempotencyClaimOutcome.Acquired acquired -> acquired.owner(); + case IdempotencyClaimOutcome.ReplayedAcquire replayed -> replayed.owner(); + case IdempotencyClaimOutcome.TakenOverClaimed takenOver -> takenOver.owner(); + default -> throw recovery("unsupported claim outcome"); + }; + return startAndRun(request, owner, action, codec, false); + } + + private R reconcileClaim( + IdempotencyClaimRequest request, + IdempotentAction action, + IdempotentResponseCodec codec) { + IdempotencyInspection inspection = + store.inspect( + new IdempotencyInspectionRequest( + request.scope(), request.fingerprint(), request.claimAttempt())); + return switch (inspection) { + case IdempotencyInspection.ClaimedSameOperation claimed -> + startAndRun(request, claimed.owner(), action, codec, false); + case IdempotencyInspection.ExecutingSameOperation executing -> + runStarted(request, executing.owner(), action, codec); + case IdempotencyInspection.CompletedReplay replay -> + codec.deserialize(replay.response().payload()); + case IdempotencyInspection.FingerprintMismatch ignored -> + throw new IdempotencyRequestMismatchException(request.scope()); + case IdempotencyInspection.Unavailable ignored -> throw new IdempotencyUnavailableException(); + default -> throw recovery("indeterminate claim cannot be safely resumed"); + }; + } + + private R startAndRun( + IdempotencyClaimRequest request, + IdempotencyOwner owner, + IdempotentAction action, + IdempotentResponseCodec codec, + boolean retriedStart) { + String operationId = request.claimAttempt().operationId(); + IdempotencyStartOutcome started = store.markExecutionStarted(owner, operationId); + if (started.status() == IdempotencyStartOutcome.Status.INDETERMINATE && !retriedStart) { + IdempotencyInspection inspection = + store.inspect( + new IdempotencyInspectionRequest( + request.scope(), request.fingerprint(), request.claimAttempt())); + if (inspection instanceof IdempotencyInspection.ClaimedSameOperation claimed) { + return startAndRun(request, claimed.owner(), action, codec, true); + } + if (inspection instanceof IdempotencyInspection.ExecutingSameOperation executing) { + return runStarted(request, executing.owner(), action, codec); + } + if (inspection instanceof IdempotencyInspection.CompletedReplay replay) { + return codec.deserialize(replay.response().payload()); + } + throw recovery("execution start is indeterminate"); + } + if (started.status() == IdempotencyStartOutcome.Status.UNAVAILABLE) { + throw new IdempotencyUnavailableException(); + } + if (started.status() != IdempotencyStartOutcome.Status.STARTED + && started.status() != IdempotencyStartOutcome.Status.ALREADY_STARTED_SAME_OPERATION) { + throw recovery("execution start was not confirmed for the exact operation"); + } + return runStarted(request, owner, action, codec); + } + + private R runStarted( + IdempotencyClaimRequest request, + IdempotencyOwner owner, + IdempotentAction action, + IdempotentResponseCodec codec) { + String operationId = request.claimAttempt().operationId(); + IdempotentAction.Outcome outcome; + try { + outcome = Objects.requireNonNull(action.run(), "action outcome must be non-null"); + } catch (RuntimeException failure) { + preserveUnknown(owner, operationId); + throw failure; + } + return switch (outcome) { + case IdempotentAction.Outcome.Success success -> + complete(request, owner, operationId, success.result(), codec, false); + case IdempotentAction.Outcome.RetryableNoEffect retryable -> { + store.markFailed( + owner, + IdempotencyFailureDisposition.RETRYABLE_NO_EFFECT, + failureRetention, + operationId); + throw retryable.failure(); + } + case IdempotentAction.Outcome.EffectUnknown unknown -> { + preserveUnknown(owner, operationId); + throw unknown.failure(); + } + }; + } + + private R complete( + IdempotencyClaimRequest request, + IdempotencyOwner owner, + String operationId, + R result, + IdempotentResponseCodec codec, + boolean retried) { + StoredResponse response = new StoredResponse(codec.serialize(result)); + IdempotencyCompleteOutcome completed = store.complete(owner, response, replayTtl, operationId); + if (completed.status() == IdempotencyCompleteOutcome.Status.COMPLETED + || completed.status() == IdempotencyCompleteOutcome.Status.ALREADY_COMPLETED_SAME_RESULT) { + return result; + } + if (completed.status() == IdempotencyCompleteOutcome.Status.INDETERMINATE) { + IdempotencyInspection inspection = + store.inspect( + new IdempotencyInspectionRequest( + request.scope(), request.fingerprint(), request.claimAttempt())); + if (inspection instanceof IdempotencyInspection.CompletedReplay replay) { + if (response.equals(replay.response())) { + return result; + } + throw recovery("completion replay conflicts with the local response"); + } + if (inspection instanceof IdempotencyInspection.ExecutingSameOperation && !retried) { + return complete(request, owner, operationId, result, codec, true); + } + throw recovery("completion response is indeterminate and could not be reconciled"); + } + if (completed.status() == IdempotencyCompleteOutcome.Status.UNAVAILABLE) { + throw new IdempotencyUnavailableException(); + } + throw recovery("completion was not confirmed"); + } + + private void preserveUnknown(IdempotencyOwner owner, String operationId) { + store.markFailed( + owner, + IdempotencyFailureDisposition.ABANDONED_EFFECT_UNKNOWN, + failureRetention, + operationId); + } + + private static IdempotencyRecoveryRequiredException recovery(String message) { + return new IdempotencyRecoveryRequiredException(message); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailOutcome.java new file mode 100644 index 0000000..e7ce8e2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailOutcome.java @@ -0,0 +1,51 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Owner-safe failed/abandoned transition result. */ +public record IdempotencyFailOutcome(Status status, String operationId) { + + public IdempotencyFailOutcome { + Objects.requireNonNull(status, "status must be non-null"); + operationId = validateOperation(status, operationId); + } + + public static IdempotencyFailOutcome operationConflict() { + return new IdempotencyFailOutcome(Status.OPERATION_CONFLICT, null); + } + + public static IdempotencyFailOutcome indeterminate(String operationId) { + return new IdempotencyFailOutcome(Status.INDETERMINATE, operationId); + } + + public static IdempotencyFailOutcome unavailable() { + return new IdempotencyFailOutcome(Status.UNAVAILABLE, null); + } + + @Override + public String toString() { + return "IdempotencyFailOutcome[status=" + status + ", operationId=REDACTED]"; + } + + private static String validateOperation(Status status, String operationId) { + if (status == Status.INDETERMINATE) { + return IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + if (operationId != null) { + throw new IllegalArgumentException("operationId is valid only for INDETERMINATE"); + } + return null; + } + + public enum Status { + MARKED_RETRYABLE, + MARKED_ABANDONED, + ALREADY_MARKED_SAME_OPERATION, + ABSENT, + NOT_OWNER, + NOT_IN_PROGRESS, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailureDisposition.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailureDisposition.java new file mode 100644 index 0000000..36d37b0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyFailureDisposition.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.idempotency; + +/** Application-confirmed effect disposition after execution started. */ +public enum IdempotencyFailureDisposition { + RETRYABLE_NO_EFFECT, + ABANDONED_EFFECT_UNKNOWN +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspection.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspection.java new file mode 100644 index 0000000..7ab582a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspection.java @@ -0,0 +1,69 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Instant; +import java.util.Objects; + +/** Read-only result used to reconcile claim/start responses without creating a new owner. */ +public sealed interface IdempotencyInspection { + + record Absent() implements IdempotencyInspection {} + + record ClaimedSameOperation(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyInspection { + + public ClaimedSameOperation { + Objects.requireNonNull(owner, "owner must be non-null"); + IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record ExecutingSameOperation(IdempotencyOwner owner, Instant processingLeaseUntil) + implements IdempotencyInspection { + + public ExecutingSameOperation { + Objects.requireNonNull(owner, "owner must be non-null"); + IdempotencyV2Validation.instant(processingLeaseUntil, "processingLeaseUntil"); + } + } + + record CompletedReplay(StoredResponse response, Instant replayUntil) + implements IdempotencyInspection { + + public CompletedReplay { + Objects.requireNonNull(response, "response must be non-null"); + IdempotencyV2Validation.instant(replayUntil, "replayUntil"); + } + + @Override + public String toString() { + return "CompletedReplay[response=REDACTED, replayUntil=" + replayUntil + "]"; + } + } + + record InProgressOther(long currentAttempt) implements IdempotencyInspection { + + public InProgressOther { + currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt"); + } + } + + record FailedRetryable(long currentAttempt) implements IdempotencyInspection { + + public FailedRetryable { + currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt"); + } + } + + record Abandoned(long currentAttempt) implements IdempotencyInspection { + + public Abandoned { + currentAttempt = IdempotencyV2Validation.positiveAttempt(currentAttempt, "currentAttempt"); + } + } + + record FingerprintMismatch() implements IdempotencyInspection {} + + record OperationConflict() implements IdempotencyInspection {} + + record Unavailable() implements IdempotencyInspection {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspectionRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspectionRequest.java new file mode 100644 index 0000000..0b1fc3d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInspectionRequest.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Read-only reconciliation input for a retained claim attempt after an uncertain response. */ +public record IdempotencyInspectionRequest( + IdempotencyScope scope, RequestFingerprint fingerprint, IdempotencyClaimAttempt claimAttempt) { + + public IdempotencyInspectionRequest { + Objects.requireNonNull(scope, "scope must be non-null"); + Objects.requireNonNull(fingerprint, "fingerprint must be non-null"); + Objects.requireNonNull(claimAttempt, "claimAttempt must be non-null"); + } + + @Override + public String toString() { + return "IdempotencyInspectionRequest[REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyOwner.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyOwner.java new file mode 100644 index 0000000..91c57bd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyOwner.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Owner-safe handle returned by a successful v2 claim. */ +public record IdempotencyOwner(IdempotencyScope scope, String ownerToken, long attempt) { + + public IdempotencyOwner { + Objects.requireNonNull(scope, "scope must be non-null"); + ownerToken = IdempotencyV2Validation.opaqueToken(ownerToken, "ownerToken"); + attempt = IdempotencyV2Validation.positiveAttempt(attempt, "attempt"); + } + + @Override + public String toString() { + return "IdempotencyOwner[attempt=" + attempt + ", scope=REDACTED, ownerToken=REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecoveryRequiredException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecoveryRequiredException.java new file mode 100644 index 0000000..4e4b07f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecoveryRequiredException.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.idempotency; + +/** Raised when request replay cannot safely decide whether an external effect already occurred. */ +public final class IdempotencyRecoveryRequiredException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public IdempotencyRecoveryRequiredException(String message) { + super(message); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyReleaseOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyReleaseOutcome.java new file mode 100644 index 0000000..b5e6c23 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyReleaseOutcome.java @@ -0,0 +1,54 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Owner-safe release result valid only before execution starts. */ +public record IdempotencyReleaseOutcome(Status status, String operationId) { + + public IdempotencyReleaseOutcome { + Objects.requireNonNull(status, "status must be non-null"); + operationId = validateOperation(status, operationId); + } + + public static IdempotencyReleaseOutcome executionAlreadyStarted() { + return new IdempotencyReleaseOutcome(Status.EXECUTION_ALREADY_STARTED, null); + } + + public static IdempotencyReleaseOutcome operationConflict() { + return new IdempotencyReleaseOutcome(Status.OPERATION_CONFLICT, null); + } + + public static IdempotencyReleaseOutcome indeterminate(String operationId) { + return new IdempotencyReleaseOutcome(Status.INDETERMINATE, operationId); + } + + public static IdempotencyReleaseOutcome unavailable() { + return new IdempotencyReleaseOutcome(Status.UNAVAILABLE, null); + } + + @Override + public String toString() { + return "IdempotencyReleaseOutcome[status=" + status + ", operationId=REDACTED]"; + } + + private static String validateOperation(Status status, String operationId) { + if (status == Status.INDETERMINATE) { + return IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + if (operationId != null) { + throw new IllegalArgumentException("operationId is valid only for INDETERMINATE"); + } + return null; + } + + public enum Status { + RELEASED_BEFORE_EXECUTION, + ALREADY_RELEASED_SAME_OPERATION, + ABSENT, + NOT_OWNER, + EXECUTION_ALREADY_STARTED, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRenewOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRenewOutcome.java new file mode 100644 index 0000000..0bc7c55 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRenewOutcome.java @@ -0,0 +1,50 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Owner-safe processing lease renewal result. */ +public record IdempotencyRenewOutcome(Status status, String operationId) { + + public IdempotencyRenewOutcome { + Objects.requireNonNull(status, "status must be non-null"); + operationId = validateOperation(status, operationId); + } + + public static IdempotencyRenewOutcome operationConflict() { + return new IdempotencyRenewOutcome(Status.OPERATION_CONFLICT, null); + } + + public static IdempotencyRenewOutcome indeterminate(String operationId) { + return new IdempotencyRenewOutcome(Status.INDETERMINATE, operationId); + } + + public static IdempotencyRenewOutcome unavailable() { + return new IdempotencyRenewOutcome(Status.UNAVAILABLE, null); + } + + @Override + public String toString() { + return "IdempotencyRenewOutcome[status=" + status + ", operationId=REDACTED]"; + } + + private static String validateOperation(Status status, String operationId) { + if (status == Status.INDETERMINATE) { + return IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + if (operationId != null) { + throw new IllegalArgumentException("operationId is valid only for INDETERMINATE"); + } + return null; + } + + public enum Status { + RENEWED, + ALREADY_RENEWED_SAME_OPERATION, + ABSENT, + NOT_OWNER, + NOT_IN_PROGRESS, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStartOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStartOutcome.java new file mode 100644 index 0000000..6be97ca --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStartOutcome.java @@ -0,0 +1,50 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** Owner-safe {@code CLAIMED -> EXECUTING} transition result. */ +public record IdempotencyStartOutcome(Status status, String operationId) { + + public IdempotencyStartOutcome { + Objects.requireNonNull(status, "status must be non-null"); + operationId = validateOperation(status, operationId); + } + + public static IdempotencyStartOutcome operationConflict() { + return new IdempotencyStartOutcome(Status.OPERATION_CONFLICT, null); + } + + public static IdempotencyStartOutcome indeterminate(String operationId) { + return new IdempotencyStartOutcome(Status.INDETERMINATE, operationId); + } + + public static IdempotencyStartOutcome unavailable() { + return new IdempotencyStartOutcome(Status.UNAVAILABLE, null); + } + + @Override + public String toString() { + return "IdempotencyStartOutcome[status=" + status + ", operationId=REDACTED]"; + } + + private static String validateOperation(Status status, String operationId) { + if (status == Status.INDETERMINATE) { + return IdempotencyV2Validation.opaqueToken(operationId, "operationId"); + } + if (operationId != null) { + throw new IllegalArgumentException("operationId is valid only for INDETERMINATE"); + } + return null; + } + + public enum Status { + STARTED, + ALREADY_STARTED_SAME_OPERATION, + ABSENT, + NOT_OWNER, + NOT_CLAIMED, + OPERATION_CONFLICT, + INDETERMINATE, + UNAVAILABLE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePortV2.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePortV2.java new file mode 100644 index 0000000..180748b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePortV2.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; + +/** + * Owner-safe request-replay store contract. + * + *

This contract does not promise cross-store exactly-once. Every mutation compares the owner + * token and attempt, and every uncertain response remains inspectable with the caller-retained + * operation token. + */ +public interface IdempotencyStorePortV2 { + + IdempotencyClaimAttempt newClaimAttempt(String operationId); + + IdempotencyClaimOutcome claim(IdempotencyClaimRequest request); + + IdempotencyStartOutcome markExecutionStarted(IdempotencyOwner owner, String operationId); + + IdempotencyRenewOutcome renew( + IdempotencyOwner owner, Duration processingLeaseTtl, String operationId); + + IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId); + + IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + String operationId); + + IdempotencyReleaseOutcome releaseBeforeExecution(IdempotencyOwner owner, String operationId); + + IdempotencyInspection inspect(IdempotencyInspectionRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyUnavailableException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyUnavailableException.java new file mode 100644 index 0000000..1ff922c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyUnavailableException.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.idempotency; + +/** Fail-closed signal raised before an action when request-replay coordination is unavailable. */ +public final class IdempotencyUnavailableException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public IdempotencyUnavailableException() { + super("idempotency coordination is unavailable"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyV2Validation.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyV2Validation.java new file mode 100644 index 0000000..5d2b1d2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyV2Validation.java @@ -0,0 +1,50 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +final class IdempotencyV2Validation { + + static final Duration MAXIMUM_PROCESSING_LEASE = Duration.ofHours(24); + static final Duration MAXIMUM_REPLAY_TTL = Duration.ofDays(30); + static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5); + + private IdempotencyV2Validation() {} + + static String opaqueToken(String value, String field) { + if (value == null + || value.length() < 16 + || value.length() > 128 + || !value.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException(field + " must contain 16..128 Base64URL-safe characters"); + } + return value; + } + + static String boundedId(String value, String field) { + if (value == null || !value.matches("[a-z][a-z0-9._-]{0,62}")) { + throw new IllegalArgumentException(field + " must be a bounded identifier"); + } + return value; + } + + static Duration positiveBounded(Duration value, Duration maximum, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + return value; + } + + static Instant instant(Instant value, String field) { + return Objects.requireNonNull(value, field + " must be non-null"); + } + + static long positiveAttempt(long value, String field) { + if (value < 1 || value > 1_000_000_000L) { + throw new IllegalArgumentException(field + " must be in 1..1000000000"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentAction.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentAction.java new file mode 100644 index 0000000..3b685b1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentAction.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** + * Explicitly classifies action effects after execution has started. + * + *

An ordinary thrown exception is intentionally not classified as no-effect; the v2 executor + * treats it as effect-unknown and preserves recovery evidence. + */ +@FunctionalInterface +public interface IdempotentAction { + + Outcome run(); + + sealed interface Outcome { + + record Success(R result) implements Outcome {} + + record RetryableNoEffect(RuntimeException failure) implements Outcome { + + public RetryableNoEffect { + Objects.requireNonNull(failure, "failure must be non-null"); + } + } + + record EffectUnknown(RuntimeException failure) implements Outcome { + + public EffectUnknown { + Objects.requireNonNull(failure, "failure must be non-null"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java index 0171ec5..bb0aa39 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java @@ -18,9 +18,9 @@ public record RequestFingerprint(String hex) { public RequestFingerprint { Objects.requireNonNull(hex, "hex"); - if (hex.length() != 64) { + if (!hex.matches("[0-9a-f]{64}")) { throw new IllegalArgumentException( - "SHA-256 fingerprint must be 64 hex chars, was " + hex.length()); + "SHA-256 fingerprint must be 64 lowercase hexadecimal characters"); } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java new file mode 100644 index 0000000..1f6d558 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.lease; + +/** + * Provider-neutral v2 efficiency lease port. + * + *

Callers retain the same {@link LeaseAttempt} across acquire retries and inspection. This port + * does not provide fencing and must not be used as the sole authority for a domain invariant. + */ +public interface DistributedLeasePort { + + LeaseAttempt newAttempt(String operationId); + + LeaseAcquireOutcome tryAcquire(LeaseRequest request); + + LeaseInspectionOutcome inspect(LeaseInspectionRequest request); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAcquireOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAcquireOutcome.java new file mode 100644 index 0000000..104d9a7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAcquireOutcome.java @@ -0,0 +1,54 @@ +package dev.caskeleton.application.lease; + +import java.time.Duration; +import java.util.Objects; + +/** Typed acquire outcome retaining response-loss uncertainty. */ +public sealed interface LeaseAcquireOutcome { + + record Acquired(LeaseHandle handle) implements LeaseAcquireOutcome { + + public Acquired { + Objects.requireNonNull(handle, "handle must be non-null"); + } + } + + record ReplayedSameOperation(LeaseHandle handle) implements LeaseAcquireOutcome { + + public ReplayedSameOperation { + Objects.requireNonNull(handle, "handle must be non-null"); + } + } + + record Contended(Duration retryAfter) implements LeaseAcquireOutcome { + + public Contended { + retryAfter = + LeaseValidation.positiveBounded( + retryAfter, LeaseValidation.MAXIMUM_RETRY_AFTER, "retryAfter"); + } + } + + record OwnerOperationConflict() implements LeaseAcquireOutcome {} + + record Unavailable(LeaseUnavailableCategory category) implements LeaseAcquireOutcome { + + public Unavailable { + Objects.requireNonNull(category, "category must be non-null"); + } + } + + record Overloaded() implements LeaseAcquireOutcome {} + + record Indeterminate(String operationId) implements LeaseAcquireOutcome { + + public Indeterminate { + operationId = LeaseValidation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "Indeterminate[operationId=REDACTED]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAttempt.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAttempt.java new file mode 100644 index 0000000..b424053 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseAttempt.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.lease; + +/** Caller-retained owner and operation identity allocated before the first provider send. */ +public record LeaseAttempt(String ownerToken, String operationId) { + + public LeaseAttempt { + ownerToken = LeaseValidation.opaqueToken(ownerToken, "ownerToken"); + operationId = LeaseValidation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "LeaseAttempt[ownerToken=REDACTED, operationId=REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java new file mode 100644 index 0000000..d741031 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.lease; + +/** The generic lease reduces duplicate work but cannot authorize correctness-sensitive writes. */ +public enum LeaseGuarantee { + EFFICIENCY_ONLY +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseHandle.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseHandle.java new file mode 100644 index 0000000..26ea862 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseHandle.java @@ -0,0 +1,58 @@ +package dev.caskeleton.application.lease; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * Owner-safe efficiency lease handle. + * + *

The server expiry is diagnostic only. Implementations must calculate remaining validity from a + * local monotonic elapsed budget and move to {@link LeaseState#UNKNOWN} or {@link LeaseState#LOST} + * when renewal certainty is unavailable. + */ +public interface LeaseHandle extends AutoCloseable { + + String ownerToken(); + + String operationId(); + + Instant acquiredAt(); + + Duration remainingValidity(); + + Instant observedServerExpiry(); + + LeaseState state(); + + LeaseRenewOutcome renew(Duration leaseTtl); + + LeaseReleaseOutcome release(); + + /** + * Compatibility cleanup for try-with-resources. + * + *

Callers that need release certainty must invoke {@link #release()} and inspect its typed + * outcome before closing. + */ + @Override + default void close() { + release(); + } + + default LeaseGuarantee guarantee() { + return LeaseGuarantee.EFFICIENCY_ONLY; + } + + default boolean isUsableFor(Duration workBudget) { + Objects.requireNonNull(workBudget, "workBudget must be non-null"); + if (workBudget.isNegative()) { + throw new IllegalArgumentException("workBudget must not be negative"); + } + Duration remaining = + Objects.requireNonNull(remainingValidity(), "remainingValidity must be non-null"); + return state() == LeaseState.ACTIVE + && !remaining.isNegative() + && remaining.compareTo(workBudget) >= 0; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionOutcome.java new file mode 100644 index 0000000..d9ac929 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionOutcome.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.lease; + +import java.util.Objects; + +/** Typed reconciliation outcome for a retained acquire attempt. */ +public sealed interface LeaseInspectionOutcome { + + record Owned(LeaseHandle handle) implements LeaseInspectionOutcome { + + public Owned { + Objects.requireNonNull(handle, "handle must be non-null"); + } + } + + record Absent() implements LeaseInspectionOutcome {} + + record NotOwner() implements LeaseInspectionOutcome {} + + record OwnerOperationConflict() implements LeaseInspectionOutcome {} + + record Unavailable(LeaseUnavailableCategory category) implements LeaseInspectionOutcome { + + public Unavailable { + Objects.requireNonNull(category, "category must be non-null"); + } + } + + record Indeterminate(String operationId) implements LeaseInspectionOutcome { + + public Indeterminate { + operationId = LeaseValidation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "Indeterminate[operationId=REDACTED]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionRequest.java new file mode 100644 index 0000000..48f682e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseInspectionRequest.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.lease; + +import java.util.Objects; + +/** Read-only reconciliation request using the exact caller-retained acquire attempt. */ +public record LeaseInspectionRequest(String purpose, String resourceDigest, LeaseAttempt attempt) { + + public LeaseInspectionRequest { + purpose = LeaseValidation.purpose(purpose); + resourceDigest = LeaseValidation.resourceDigest(resourceDigest); + Objects.requireNonNull(attempt, "attempt must be non-null"); + } + + @Override + public String toString() { + return "LeaseInspectionRequest[purpose=" + + purpose + + ", resourceDigest=REDACTED, attempt=REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseReleaseOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseReleaseOutcome.java new file mode 100644 index 0000000..a5bc926 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseReleaseOutcome.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.lease; + +/** Owner-safe release outcome; blind deletion is not representable. */ +public sealed interface LeaseReleaseOutcome { + + record Released() implements LeaseReleaseOutcome {} + + record AlreadyAbsent() implements LeaseReleaseOutcome {} + + record NotOwner() implements LeaseReleaseOutcome {} + + record Indeterminate(String operationId) implements LeaseReleaseOutcome { + + public Indeterminate { + operationId = LeaseValidation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "Indeterminate[operationId=REDACTED]"; + } + } + + record Unavailable(LeaseUnavailableCategory category) implements LeaseReleaseOutcome { + + public Unavailable { + if (category == null) { + throw new IllegalArgumentException("category must be non-null"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRenewOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRenewOutcome.java new file mode 100644 index 0000000..a7f3da1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRenewOutcome.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.lease; + +import java.time.Duration; + +/** Owner-safe lease renewal outcome. */ +public sealed interface LeaseRenewOutcome { + + record Renewed(Duration remainingValidity) implements LeaseRenewOutcome { + + public Renewed { + remainingValidity = + LeaseValidation.positiveBounded( + remainingValidity, LeaseValidation.MAXIMUM_LEASE, "remainingValidity"); + } + } + + record Absent() implements LeaseRenewOutcome {} + + record NotOwner() implements LeaseRenewOutcome {} + + record Indeterminate(String operationId) implements LeaseRenewOutcome { + + public Indeterminate { + operationId = LeaseValidation.opaqueToken(operationId, "operationId"); + } + + @Override + public String toString() { + return "Indeterminate[operationId=REDACTED]"; + } + } + + record Unavailable(LeaseUnavailableCategory category) implements LeaseRenewOutcome { + + public Unavailable { + if (category == null) { + throw new IllegalArgumentException("category must be non-null"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRequest.java new file mode 100644 index 0000000..dc0aa1e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRequest.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.lease; + +import java.time.Duration; +import java.util.Objects; + +/** Bounded provider-neutral request for an efficiency lease. */ +public record LeaseRequest( + String purpose, + String resourceDigest, + Duration waitTimeout, + Duration leaseTtl, + LeaseAttempt attempt) { + + public LeaseRequest { + purpose = LeaseValidation.purpose(purpose); + resourceDigest = LeaseValidation.resourceDigest(resourceDigest); + waitTimeout = + LeaseValidation.nonNegativeBounded( + waitTimeout, LeaseValidation.MAXIMUM_WAIT, "waitTimeout"); + leaseTtl = LeaseValidation.positiveBounded(leaseTtl, LeaseValidation.MAXIMUM_LEASE, "leaseTtl"); + Objects.requireNonNull(attempt, "attempt must be non-null"); + } + + @Override + public String toString() { + return "LeaseRequest[purpose=" + + purpose + + ", resourceDigest=REDACTED, waitTimeout=" + + waitTimeout + + ", leaseTtl=" + + leaseTtl + + ", attempt=REDACTED]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseState.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseState.java new file mode 100644 index 0000000..5b2aabd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseState.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.lease; + +/** Local handle state after command certainty and validity-budget evaluation. */ +public enum LeaseState { + ACTIVE, + LOST, + RELEASED, + UNKNOWN +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseUnavailableCategory.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseUnavailableCategory.java new file mode 100644 index 0000000..7f7bc17 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseUnavailableCategory.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.lease; + +/** Provider-neutral acquisition or inspection failure category. */ +public enum LeaseUnavailableCategory { + UNAVAILABLE_BEFORE_SEND, + ADMISSION_REJECTED, + DEADLINE_EXPIRED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseValidation.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseValidation.java new file mode 100644 index 0000000..4618c61 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseValidation.java @@ -0,0 +1,67 @@ +package dev.caskeleton.application.lease; + +import java.time.Duration; +import java.util.Objects; + +final class LeaseValidation { + + static final Duration MAXIMUM_WAIT = Duration.ofSeconds(30); + static final Duration MAXIMUM_LEASE = Duration.ofHours(24); + static final Duration MAXIMUM_RETRY_AFTER = Duration.ofMinutes(5); + + private LeaseValidation() {} + + static String opaqueToken(String value, String field) { + if (value == null + || value.length() < 16 + || value.length() > 128 + || !value.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException(field + " must contain 16..128 Base64URL-safe characters"); + } + return value; + } + + static String purpose(String value) { + if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException("purpose must be a bounded identifier"); + } + return value; + } + + static String resourceDigest(String value) { + if (value == null || !value.matches("hv[1-9][0-9]{0,3}:[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "resourceDigest must be a versioned lowercase SHA-256 digest"); + } + return value; + } + + static Duration nonNegativeBounded(Duration value, Duration maximum, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be non-negative and bounded"); + } + return wholeMilliseconds(value, field); + } + + static Duration positiveBounded(Duration value, Duration maximum, String field) { + Objects.requireNonNull(value, field + " must be non-null"); + if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " must be positive and bounded"); + } + return wholeMilliseconds(value, field); + } + + private static Duration wholeMilliseconds(Duration value, String field) { + long milliseconds; + try { + milliseconds = value.toMillis(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException(field + " exceeds supported milliseconds", exception); + } + if (!Duration.ofMillis(milliseconds).equals(value)) { + throw new IllegalArgumentException(field + " must use whole milliseconds"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java new file mode 100644 index 0000000..282f43c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java @@ -0,0 +1,221 @@ +package dev.caskeleton.application.lease; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Bounded renewal scheduler for efficiency leases. + * + *

A watchdog reduces duplicate work only. It does not remove process pauses, Redis failover, or + * the requirement for a correctness authority at the protected resource. + */ +public final class LeaseWatchdog implements AutoCloseable { + + private static final int MAXIMUM_WORKERS = 32; + private static final int MAXIMUM_REGISTRATIONS = 100_000; + + private final ScheduledThreadPoolExecutor scheduler; + private final int maximumRegistrations; + private final Clock clock; + private final AtomicInteger registrations = new AtomicInteger(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final Set active = ConcurrentHashMap.newKeySet(); + + public LeaseWatchdog(int workerThreads, int maximumRegistrations, Clock clock) { + if (workerThreads < 1 || workerThreads > MAXIMUM_WORKERS) { + throw new IllegalArgumentException("workerThreads must be in 1..32"); + } + if (maximumRegistrations < 1 || maximumRegistrations > MAXIMUM_REGISTRATIONS) { + throw new IllegalArgumentException("maximumRegistrations must be in 1..100000"); + } + this.maximumRegistrations = maximumRegistrations; + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + this.scheduler = + new ScheduledThreadPoolExecutor( + workerThreads, + daemonThreadFactory(), + new java.util.concurrent.ThreadPoolExecutor.AbortPolicy()); + this.scheduler.setRemoveOnCancelPolicy(true); + this.scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + this.scheduler.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); + } + + public Registration watch( + LeaseHandle handle, + Duration leaseTtl, + Duration cadence, + Instant applicationDeadline, + Runnable cancelWork, + Consumer lostListener) { + Objects.requireNonNull(handle, "handle must be non-null"); + Duration boundedLeaseTtl = + LeaseValidation.positiveBounded(leaseTtl, LeaseValidation.MAXIMUM_LEASE, "leaseTtl"); + Duration boundedCadence = LeaseValidation.positiveBounded(cadence, boundedLeaseTtl, "cadence"); + if (boundedCadence.compareTo(boundedLeaseTtl.dividedBy(2)) > 0) { + throw new IllegalArgumentException("cadence must not exceed half of leaseTtl"); + } + Objects.requireNonNull(applicationDeadline, "applicationDeadline must be non-null"); + if (!applicationDeadline.isAfter(clock.instant())) { + throw new IllegalArgumentException("applicationDeadline must be in the future"); + } + Objects.requireNonNull(cancelWork, "cancelWork must be non-null"); + Objects.requireNonNull(lostListener, "lostListener must be non-null"); + reserve(); + Registration registration = + new Registration(handle, boundedLeaseTtl, applicationDeadline, cancelWork, lostListener); + active.add(registration); + try { + registration.future = + scheduler.scheduleWithFixedDelay( + registration::runOnce, + boundedCadence.toMillis(), + boundedCadence.toMillis(), + TimeUnit.MILLISECONDS); + return registration; + } catch (RuntimeException failure) { + registration.close(); + throw failure; + } + } + + private void reserve() { + while (true) { + if (closed.get()) { + throw new RejectedExecutionException("lease watchdog is closed"); + } + int current = registrations.get(); + if (current >= maximumRegistrations) { + throw new RejectedExecutionException("lease watchdog registration bound reached"); + } + if (registrations.compareAndSet(current, current + 1)) { + if (closed.get()) { + registrations.decrementAndGet(); + throw new RejectedExecutionException("lease watchdog is closed"); + } + return; + } + } + } + + int activeRegistrations() { + return registrations.get(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + for (Registration registration : active.toArray(Registration[]::new)) { + registration.close(); + } + for (Runnable ignored : scheduler.shutdownNow()) { + // Iteration deliberately observes the returned cancelled tasks for Error Prone compliance. + } + } + } + + public final class Registration implements AutoCloseable { + + private final LeaseHandle handle; + private final Duration leaseTtl; + private final Instant applicationDeadline; + private final Runnable cancelWork; + private final Consumer lostListener; + private final AtomicBoolean registrationClosed = new AtomicBoolean(); + private final AtomicBoolean lossReported = new AtomicBoolean(); + private volatile ScheduledFuture future; + + private Registration( + LeaseHandle handle, + Duration leaseTtl, + Instant applicationDeadline, + Runnable cancelWork, + Consumer lostListener) { + this.handle = handle; + this.leaseTtl = leaseTtl; + this.applicationDeadline = applicationDeadline; + this.cancelWork = cancelWork; + this.lostListener = lostListener; + } + + private void runOnce() { + if (registrationClosed.get()) { + return; + } + if (!applicationDeadline.isAfter(clock.instant())) { + terminateLost(LeaseState.LOST); + return; + } + LeaseState before = handle.state(); + if (before != LeaseState.ACTIVE || handle.remainingValidity().isZero()) { + terminateLost(before == LeaseState.ACTIVE ? LeaseState.LOST : before); + return; + } + LeaseRenewOutcome outcome; + try { + outcome = Objects.requireNonNull(handle.renew(leaseTtl), "renew outcome must be non-null"); + } catch (RuntimeException failure) { + terminateLost(LeaseState.UNKNOWN); + return; + } + if (!(outcome instanceof LeaseRenewOutcome.Renewed) || handle.state() != LeaseState.ACTIVE) { + LeaseState state = handle.state(); + terminateLost(state == LeaseState.ACTIVE ? LeaseState.LOST : state); + } + } + + void runOnceForTest() { + runOnce(); + } + + public boolean closed() { + return registrationClosed.get(); + } + + private void terminateLost(LeaseState state) { + if (lossReported.compareAndSet(false, true)) { + try { + cancelWork.run(); + } finally { + try { + lostListener.accept(state); + } finally { + close(); + } + } + } + } + + @Override + public void close() { + if (registrationClosed.compareAndSet(false, true)) { + ScheduledFuture scheduled = future; + if (scheduled != null) { + scheduled.cancel(false); + } + active.remove(this); + registrations.decrementAndGet(); + } + } + } + + private static ThreadFactory daemonThreadFactory() { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, "ca-lease-watchdog-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java new file mode 100644 index 0000000..5b5e21b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Applies one already authenticated and normalized receipt. */ +public record ApplyNotificationReceiptCommand(NormalizedNotificationReceiptCommand receipt) + implements Command { + + public ApplyNotificationReceiptCommand { + Objects.requireNonNull(receipt, "normalized notification receipt must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java new file mode 100644 index 0000000..2dc4c00 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Non-sensitive result of reducing one receipt event. */ +public record ApplyNotificationReceiptResult( + Status status, NotificationReceiptProjection projection, boolean suppressionApplied) { + + public ApplyNotificationReceiptResult { + Objects.requireNonNull(status, "notification receipt apply status must be non-null"); + Objects.requireNonNull(projection, "notification receipt projection must be non-null"); + if (status == Status.DUPLICATE && suppressionApplied) { + throw new IllegalArgumentException("duplicate receipt cannot repeat technical suppression"); + } + } + + public enum Status { + APPLIED, + DUPLICATE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java new file mode 100644 index 0000000..74c12ea --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java @@ -0,0 +1,80 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** + * Appends one receipt fact and reduces its delivery projection in one physical root transaction. + */ +@RequiresPermission("notification:receipt") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public final class ApplyNotificationReceiptUseCase + implements CommandUseCase { + + private final NotificationReceiptStorePort store; + private final NotificationTechnicalSuppressionPort suppression; + private final TransactionPort transactions; + private final Clock clock; + + public ApplyNotificationReceiptUseCase( + NotificationReceiptStorePort store, + NotificationTechnicalSuppressionPort suppression, + TransactionPort transactions, + Clock clock) { + this.store = Objects.requireNonNull(store, "notification receipt store must be non-null"); + this.suppression = + Objects.requireNonNull(suppression, "notification suppression port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public ApplyNotificationReceiptResult handle(ApplyNotificationReceiptCommand command) { + Objects.requireNonNull(command, "apply notification receipt command must be non-null"); + return transactions.inRootWrite(() -> applyInsideRoot(command.receipt())); + } + + private ApplyNotificationReceiptResult applyInsideRoot( + NormalizedNotificationReceiptCommand command) { + NotificationReceiptStorePort.AppendResult appendResult = store.appendIfAbsent(command); + if (appendResult instanceof NotificationReceiptStorePort.Duplicate duplicate) { + return new ApplyNotificationReceiptResult( + ApplyNotificationReceiptResult.Status.DUPLICATE, duplicate.projection(), false); + } + + NotificationReceiptStorePort.ReceiptAggregate aggregate = + ((NotificationReceiptStorePort.Appended) appendResult).aggregate(); + if (!aggregate.deliveryId().equals(command.deliveryId())) { + throw new IllegalStateException( + "receipt aggregate delivery does not match normalized command"); + } + NotificationReceiptProjection projection = + NotificationReceiptProjection.reduce(aggregate.facts()); + store.saveProjection(aggregate.deliveryId(), projection); + + boolean suppressionApplied = shouldSuppress(command.fact()); + if (suppressionApplied) { + suppression.suppress( + new NotificationTechnicalSuppressionPort.SuppressionMutation( + aggregate.recipient(), command.fact().reasonCode(), clock.instant())); + } + return new ApplyNotificationReceiptResult( + ApplyNotificationReceiptResult.Status.APPLIED, projection, suppressionApplied); + } + + private static boolean shouldSuppress(NotificationReceiptFact fact) { + return fact.type() == NotificationReceiptFact.Type.COMPLAINT + || (fact.type() == NotificationReceiptFact.Type.BOUNCE + && fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java new file mode 100644 index 0000000..f24e7fc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Point at which recipient consent or preference must be established. */ +public enum ConsentCheckMode { + SNAPSHOT_AT_APPEND, + RECHECK_BEFORE_EACH_DELIVERY +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java new file mode 100644 index 0000000..2db363f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.notification; + +/** Opaque reference resolved to an email recipient only inside a qualified adapter. */ +public record EmailRecipientReference(String reference) implements NotificationRecipientReference { + + public EmailRecipientReference { + reference = NotificationIntentId.requireOpaque("email recipient reference", reference); + } + + @Override + public NotificationChannel channel() { + return NotificationChannel.EMAIL; + } + + @Override + public String toString() { + return "EmailRecipientReference[reference=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java new file mode 100644 index 0000000..34e689f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Reviewed all-route initialization request; partial route sets are rejected by the use case. */ +public record InitializeNotificationWriterFencesCommand( + String operationToken, + NotificationCanonicalWriterRouteSet reviewedRoutes, + String reviewedRouteSetDigest, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public InitializeNotificationWriterFencesCommand { + operationToken = + NotificationIntentId.requireOpaque("writer initialization operation token", operationToken); + Objects.requireNonNull(reviewedRoutes, "reviewed writer routes must be non-null"); + reviewedRouteSetDigest = requireDigest(reviewedRouteSetDigest); + actorReference = + NotificationIntentId.requireOpaque("writer initialization actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + + static String requireDigest(String digest) { + if (digest == null || !digest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "writer evidence digest must be 64 lowercase hex characters"); + } + return digest; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java new file mode 100644 index 0000000..809f55b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Atomic persistence operation for absent-fence and empty-journal initialization. */ +@FunctionalInterface +public interface InitializeNotificationWriterFencesOperation { + + InitializeNotificationWriterFencesResult initialize( + InitializeNotificationWriterFencesCommand command, + NotificationWriterRouteSet trustedRoutes, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java new file mode 100644 index 0000000..850b3fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed all-route fence initialization result. */ +public record InitializeNotificationWriterFencesResult( + Status status, int initializedRouteCount, String routeSetDigest) { + + public InitializeNotificationWriterFencesResult { + Objects.requireNonNull(status, "writer initialization status must be non-null"); + if (initializedRouteCount < 1 || initializedRouteCount > 100) { + throw new IllegalArgumentException("initialized writer route count must be in 1..100"); + } + routeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(routeSetDigest); + } + + public enum Status { + INITIALIZED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java new file mode 100644 index 0000000..0d07984 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java @@ -0,0 +1,55 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Root-commits the complete trusted writer fence and proof registry initialization batch. */ +@RequiresPermission("notification:cutover") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class InitializeNotificationWriterFencesUseCase + implements CommandUseCase< + InitializeNotificationWriterFencesCommand, InitializeNotificationWriterFencesResult> { + + private final NotificationWriterRouteSet routes; + private final InitializeNotificationWriterFencesOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public InitializeNotificationWriterFencesUseCase( + NotificationWriterRouteSet routes, + InitializeNotificationWriterFencesOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.operation = + Objects.requireNonNull(operation, "writer initialization operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public InitializeNotificationWriterFencesResult handle( + InitializeNotificationWriterFencesCommand command) { + Objects.requireNonNull(command, "writer initialization command must be non-null"); + if (!command.reviewedRoutes().equals(routes.canonicalRoutes())) { + throw new IllegalArgumentException( + "reviewed writer routes must exactly equal the trusted all-route set"); + } + if (!command.reviewedRouteSetDigest().equals(routes.digest())) { + throw new IllegalArgumentException( + "reviewed writer route-set digest does not match trusted registry digest"); + } + return transactions.inRootWrite(() -> operation.initialize(command, routes, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java new file mode 100644 index 0000000..021d0ef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.notification; + +/** + * Executes one bounded, non-durable inline attempt over a frozen application plan. The caller must + * establish the physical root-write sequencing contract before invoking this port. + */ +@FunctionalInterface +public interface InlineNotificationAttemptPort { + + NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java new file mode 100644 index 0000000..cb7b716 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Framework-free receipt normalized by an authenticated inbound adapter. */ +public record NormalizedNotificationReceiptCommand( + NotificationReceiptEventId receiptEventId, + NotificationDeliveryId deliveryId, + NotificationReceiptFact fact) { + + public NormalizedNotificationReceiptCommand { + Objects.requireNonNull(receiptEventId, "notification receipt event ID must be non-null"); + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(fact, "notification receipt fact must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java new file mode 100644 index 0000000..d80a25c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Code-owned dispatch admission and fairness class. */ +public enum NotificationAdmissionClass { + SECURITY_CRITICAL, + TRANSACTIONAL, + BULK_LOW_VALUE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java new file mode 100644 index 0000000..70072b2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java @@ -0,0 +1,53 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Audited operator request to re-probe and resume one shared notification admission gate. */ +public record NotificationAdmissionGateCommand( + String operationToken, + NotificationRouteId routeId, + int policyRevision, + NotificationFaultScope faultScope, + String scopeReference, + long expectedGeneration, + int maximumParkedLegs, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public NotificationAdmissionGateCommand { + operationToken = + NotificationIntentId.requireOpaque("admission resume operation token", operationToken); + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (policyRevision < 1 || expectedGeneration < 0) { + throw new IllegalArgumentException( + "policy revision must be positive and expected generation non-negative"); + } + if (maximumParkedLegs < 1 || maximumParkedLegs > 100) { + throw new IllegalArgumentException("maximum parked legs must be in 1..100"); + } + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + if (faultScope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("operator admission command cannot target DELIVERY scope"); + } + scopeReference = + NotificationIntentId.requireOpaque("admission scope reference", scopeReference); + actorReference = + NotificationIntentId.requireOpaque("admission resume actor reference", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + + NotificationAdmissionReadinessPort.ResumeRequest toResumeRequest() { + return new NotificationAdmissionReadinessPort.ResumeRequest( + operationToken, + routeId, + policyRevision, + faultScope, + scopeReference, + expectedGeneration, + maximumParkedLegs, + actorReference, + reasonCode); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java new file mode 100644 index 0000000..acd6679 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java @@ -0,0 +1,89 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Probes readiness outside a transaction and generation-CAS resumes inside one short write. */ +@RequiresPermission("notification:operate") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + crossTenantAdmin = true) +public final class NotificationAdmissionGateUseCase + implements CommandUseCase< + NotificationAdmissionGateCommand, NotificationAdmissionGateUseCase.Result> { + + private final NotificationAdmissionReadinessPort admission; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationAdmissionGateUseCase( + NotificationAdmissionReadinessPort admission, TransactionPort transactions, Clock clock) { + this.admission = + Objects.requireNonNull(admission, "notification admission port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public Result handle(NotificationAdmissionGateCommand command) { + Objects.requireNonNull(command, "notification admission command must be non-null"); + NotificationAdmissionReadinessPort.ResumeRequest request = command.toResumeRequest(); + NotificationAdmissionReadinessPort.ReadinessProbe probe = + Objects.requireNonNull(admission.probe(request), "readiness probe must be non-null"); + if (!probe.ready()) { + return new Result(Result.Status.NOT_READY, probe.reasonCode()); + } + NotificationAdmissionReadinessPort.ResumeResult resumeResult = + Objects.requireNonNull( + transactions.inWrite(() -> admission.resume(request, probe, clock.instant())), + "notification admission resume result must be non-null"); + validateResumeResult(request, resumeResult); + return switch (resumeResult.status()) { + case RESUMED -> new Result(Result.Status.RESUMED, probe.reasonCode()); + case ALREADY_ACTIVE -> new Result(Result.Status.ALREADY_ACTIVE, probe.reasonCode()); + case STALE_GENERATION -> + new Result( + Result.Status.STALE_GENERATION, + new NotificationReasonCode("STALE_ADMISSION_GENERATION")); + }; + } + + private static void validateResumeResult( + NotificationAdmissionReadinessPort.ResumeRequest request, + NotificationAdmissionReadinessPort.ResumeResult result) { + if (result.processedLegCount() > request.maximumParkedLegs()) { + throw new IllegalArgumentException( + "admission resume processed more parked legs than the requested bound"); + } + if (result.status() == NotificationAdmissionReadinessPort.ResumeStatus.RESUMED + && result.resultingGeneration() != request.expectedGeneration() + 1) { + throw new IllegalArgumentException( + "resumed admission gate must advance the exact expected generation"); + } + } + + public record Result(Status status, NotificationReasonCode reasonCode) { + + public Result { + Objects.requireNonNull(status, "notification admission result status must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + + public enum Status { + RESUMED, + ALREADY_ACTIVE, + NOT_READY, + STALE_GENERATION + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java new file mode 100644 index 0000000..5e490d5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java @@ -0,0 +1,154 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** Persists shared route/provider/account admission readiness with generation-guarded CAS. */ +@FunctionalInterface +public interface NotificationAdmissionReadinessPort { + + ParkResult park(ParkRequest request); + + default ReadinessProbe probe(ResumeRequest request) { + throw new UnsupportedOperationException("notification readiness probe is not implemented"); + } + + default ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + throw new UnsupportedOperationException("notification admission resume is not implemented"); + } + + record ParkRequest( + NotificationRouteId routeId, + int policyRevision, + NotificationFaultScope faultScope, + String scopeReference, + long expectedGeneration, + NotificationReasonCode reasonCode, + Instant parkedAt) { + + public ParkRequest { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (policyRevision < 1 || expectedGeneration < 0) { + throw new IllegalArgumentException( + "policy revision must be positive and expected generation non-negative"); + } + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + if (faultScope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope"); + } + scopeReference = + NotificationIntentId.requireOpaque("admission scope reference", scopeReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(parkedAt, "admission parked time must be non-null"); + } + } + + enum ParkResult { + NOT_REQUESTED, + PARKED, + ALREADY_PARKED, + STALE_GENERATION + } + + record ResumeRequest( + String operationToken, + NotificationRouteId routeId, + int policyRevision, + NotificationFaultScope faultScope, + String scopeReference, + long expectedGeneration, + int maximumParkedLegs, + String actorReference, + NotificationReasonCode reasonCode) { + + public ResumeRequest { + operationToken = + NotificationIntentId.requireOpaque("admission resume operation token", operationToken); + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (policyRevision < 1 || expectedGeneration < 0) { + throw new IllegalArgumentException( + "policy revision must be positive and expected generation non-negative"); + } + if (maximumParkedLegs < 1 || maximumParkedLegs > 100) { + throw new IllegalArgumentException("maximum parked legs must be in 1..100"); + } + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + if (faultScope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope"); + } + scopeReference = + NotificationIntentId.requireOpaque("admission scope reference", scopeReference); + actorReference = + NotificationIntentId.requireOpaque("admission resume actor reference", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record ReadinessProbe(boolean ready, NotificationReasonCode reasonCode) { + + public ReadinessProbe { + Objects.requireNonNull(reasonCode, "notification readiness reason must be non-null"); + } + } + + enum ResumeStatus { + RESUMED, + ALREADY_ACTIVE, + STALE_GENERATION + } + + /** + * Audited bounded result of rechecking every selected parked leg inside the gate-resume + * transaction. Initial R1 never activates fallback while resuming a binding park. + */ + record ResumeResult( + ResumeStatus status, + long resultingGeneration, + int queuedCount, + int expiredCount, + int cancelledCount, + int technicallySuppressedCount, + int policyRejectedCount, + int activatedFallbackCount) { + + public ResumeResult { + Objects.requireNonNull(status, "notification admission resume status must be non-null"); + if (resultingGeneration < 0 + || queuedCount < 0 + || expiredCount < 0 + || cancelledCount < 0 + || technicallySuppressedCount < 0 + || policyRejectedCount < 0 + || activatedFallbackCount < 0) { + throw new IllegalArgumentException( + "notification admission resume generation/counts must be non-negative"); + } + int processedLegCount = + queuedCount + + expiredCount + + cancelledCount + + technicallySuppressedCount + + policyRejectedCount; + if (processedLegCount > 100) { + throw new IllegalArgumentException( + "notification admission resume leg count must be bounded by 100"); + } + if (activatedFallbackCount != 0) { + throw new IllegalArgumentException( + "binding-park resume must not activate initial fallback legs"); + } + if (status != ResumeStatus.RESUMED && processedLegCount != 0) { + throw new IllegalArgumentException( + "non-mutating admission resume status cannot report processed legs"); + } + } + + public int processedLegCount() { + return queuedCount + + expiredCount + + cancelledCount + + technicallySuppressedCount + + policyRejectedCount; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java new file mode 100644 index 0000000..81c9e51 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Durable append result; neither appended nor duplicate means provider delivery succeeded. */ +public sealed interface NotificationAppendResult + permits NotificationAppendResult.Appended, + NotificationAppendResult.DuplicateExisting, + NotificationAppendResult.Rejected { + + record Appended(NotificationIntentId intentId) implements NotificationAppendResult { + + public Appended { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record DuplicateExisting(NotificationIntentId intentId) implements NotificationAppendResult { + + public DuplicateExisting { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record Rejected(NotificationReasonCode reasonCode) implements NotificationAppendResult { + + public Rejected { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java new file mode 100644 index 0000000..ee5570e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Framework- and provider-neutral application failure carrying only a stable reason code. */ +public final class NotificationApplicationException extends RuntimeException { + + private final NotificationReasonCode reasonCode; + + public NotificationApplicationException(NotificationReasonCode reasonCode, Throwable cause) { + super( + Objects.requireNonNull(reasonCode, "notification reason code must be non-null").value(), + cause); + this.reasonCode = reasonCode; + } + + public NotificationReasonCode reasonCode() { + return reasonCode; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java new file mode 100644 index 0000000..516be32 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity of one authorized physical provider attempt. */ +public record NotificationAttemptId(String value) { + + public NotificationAttemptId { + value = NotificationIntentId.requireOpaque("attemptId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java new file mode 100644 index 0000000..0eafc3a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java @@ -0,0 +1,44 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** + * Internal application collaborator asserting canonical ownership in an existing write boundary. + */ +public final class NotificationCanonicalWriterFenceGuard { + + private final NotificationCanonicalWriterFencePort fence; + private final NotificationCanonicalWriterRouteSet routes; + + public NotificationCanonicalWriterFenceGuard( + NotificationCanonicalWriterFencePort fence, NotificationCanonicalWriterRouteSet routes) { + this.fence = Objects.requireNonNull(fence, "canonical writer fence port must be non-null"); + this.routes = Objects.requireNonNull(routes, "canonical writer route set must be non-null"); + } + + public void assertCanonical( + NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) { + if (!routes.contains(route)) { + throw new IllegalArgumentException( + "route is outside canonical notification writer route set"); + } + NotificationCanonicalWriterFencePort.FenceSnapshot snapshot = + Objects.requireNonNull( + fence.assertCanonicalInCallerTransaction( + new NotificationCanonicalWriterFencePort.FenceRequest(route, expectedGeneration)), + "canonical writer fence snapshot must be non-null"); + if (!snapshot.route().equals(route)) { + throw failure("CANONICAL_WRITER_ROUTE_MISMATCH"); + } + if (snapshot.owner() != NotificationWriterOwnership.CANONICAL) { + throw failure("CANONICAL_WRITER_NOT_OWNER"); + } + if (snapshot.generation() != expectedGeneration) { + throw failure("STALE_CANONICAL_WRITER_GENERATION"); + } + } + + private static NotificationApplicationException failure(String reason) { + return new NotificationApplicationException(new NotificationReasonCode(reason), null); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java new file mode 100644 index 0000000..420ea9d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** + * Acquires a transaction-scoped shared fence assertion. The persistence implementation must hold + * the share lock until the caller's physical commit or rollback. + */ +@FunctionalInterface +public interface NotificationCanonicalWriterFencePort { + + FenceSnapshot assertCanonicalInCallerTransaction(FenceRequest request); + + record FenceRequest( + NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) { + + public FenceRequest { + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (expectedGeneration < 0) { + throw new IllegalArgumentException("expected writer generation must be non-negative"); + } + } + } + + record FenceSnapshot( + NotificationCanonicalWriterRouteSet.RouteRevision route, + NotificationWriterOwnership owner, + long generation) { + + public FenceSnapshot { + Objects.requireNonNull(route, "notification writer route must be non-null"); + Objects.requireNonNull(owner, "notification writer owner must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("writer generation must be non-negative"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java new file mode 100644 index 0000000..3a9316e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java @@ -0,0 +1,79 @@ +package dev.caskeleton.application.notification; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** Bounded, ordered canonical route-revision set that production admission is allowed to use. */ +public record NotificationCanonicalWriterRouteSet(List routes) { + + public NotificationCanonicalWriterRouteSet { + Objects.requireNonNull(routes, "canonical notification writer routes must be non-null"); + routes = + routes.stream() + .map(route -> Objects.requireNonNull(route, "canonical route must be non-null")) + .sorted( + Comparator.comparing((RouteRevision route) -> route.routeId().value()) + .thenComparingInt(RouteRevision::routeRevision)) + .toList(); + if (routes.isEmpty() || routes.size() > 100) { + throw new IllegalArgumentException("canonical writer route set must contain 1..100 routes"); + } + if (new HashSet<>(routes).size() != routes.size()) { + throw new IllegalArgumentException("canonical writer route set contains a duplicate route"); + } + long distinctRouteKeys = routes.stream().map(RouteRevision::routeId).distinct().count(); + if (distinctRouteKeys != routes.size()) { + throw new IllegalArgumentException( + "canonical writer route set contains multiple revisions for one route key"); + } + } + + public boolean contains(RouteRevision route) { + return routes.contains(route); + } + + public String digest() { + MessageDigest digest = sha256(); + routes.forEach( + route -> { + update(digest, route.routeId().value()); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(route.routeRevision()).array()); + digest.update( + ByteBuffer.allocate(Long.BYTES).putLong(route.predecessorGeneration()).array()); + }); + return java.util.HexFormat.of().formatHex(digest.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + static void update(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + } + + public record RouteRevision( + NotificationRouteId routeId, int routeRevision, long predecessorGeneration) { + + public RouteRevision { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (routeRevision < 1 || routeRevision > 1_000_000 || predecessorGeneration < 0) { + throw new IllegalArgumentException( + "route revision must be in 1..1000000 and predecessor generation non-negative"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java new file mode 100644 index 0000000..2302f6b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java @@ -0,0 +1,92 @@ +package dev.caskeleton.application.notification; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Pure validator over application-owned policy and provider/store/ingress capability facts. */ +public final class NotificationCapabilityCompatibilityValidator { + + public Compatibility validate( + NotificationKindPolicy policy, + NotificationProviderCapabilityDescriptor provider, + NotificationStoreCapabilityDescriptor store, + Optional receiptIngress, + boolean receiptRequired) { + Objects.requireNonNull(policy, "notification kind policy must be non-null"); + Objects.requireNonNull(provider, "notification provider descriptor must be non-null"); + Objects.requireNonNull(store, "notification store descriptor must be non-null"); + Objects.requireNonNull(receiptIngress, "receipt ingress container must be non-null"); + + List reasons = new ArrayList<>(); + addIf(reasons, provider.channel() != policy.channel(), "PROVIDER_CHANNEL_MISMATCH"); + addIf(reasons, !provider.supportedModes().contains(policy.mode()), "PROVIDER_MODE_UNSUPPORTED"); + addIf(reasons, !provider.hiddenRetriesControlled(), "PROVIDER_HIDDEN_RETRY_UNCONTROLLED"); + addIf( + reasons, + provider.maximumTargets() < policy.maxTargetsPerRecipient(), + "PROVIDER_TARGET_BOUND_INSUFFICIENT"); + if (policy.mode() == NotificationMode.DURABLE_ASYNC) { + addIf( + reasons, + !store.durableIntentStore() || !store.attemptJournal(), + "DURABLE_STORE_UNAVAILABLE"); + } + addIf( + reasons, + !store.availablePolicyRevisions().contains(policy.policyRevision()), + "POLICY_REVISION_UNAVAILABLE"); + addIf( + reasons, + !store.availableTemplateRevisions().contains(policy.templateRef()), + "TEMPLATE_REVISION_UNAVAILABLE"); + if (receiptRequired) { + addIf(reasons, !provider.receiptSupported(), "PROVIDER_RECEIPT_UNSUPPORTED"); + addIf(reasons, !store.receiptInbox(), "RECEIPT_STORE_UNAVAILABLE"); + boolean ingressUnavailable = + receiptIngress.isEmpty() + || !receiptIngress.orElseThrow().enabled() + || !receiptIngress.orElseThrow().authenticated() + || receiptIngress.orElseThrow().channel() != policy.channel() + || receiptIngress.orElseThrow().supportedFactTypes().isEmpty(); + addIf(reasons, ingressUnavailable, "RECEIPT_INGRESS_UNAVAILABLE"); + } + return new Compatibility(reasons.isEmpty(), reasons); + } + + public void requireCompatible( + NotificationKindPolicy policy, + NotificationProviderCapabilityDescriptor provider, + NotificationStoreCapabilityDescriptor store, + Optional receiptIngress, + boolean receiptRequired) { + Compatibility compatibility = + validate(policy, provider, store, receiptIngress, receiptRequired); + if (!compatibility.compatible()) { + throw new NotificationApplicationException( + new NotificationReasonCode("NOTIFICATION_CAPABILITY_INCOMPATIBLE"), null); + } + } + + private static void addIf( + List reasons, boolean condition, String reasonCode) { + if (condition) { + reasons.add(new NotificationReasonCode(reasonCode)); + } + } + + public record Compatibility(boolean compatible, List reasonCodes) { + + public Compatibility { + reasonCodes = + List.copyOf( + Objects.requireNonNull( + reasonCodes, "notification compatibility reasons must be non-null")); + if (compatible != reasonCodes.isEmpty()) { + throw new IllegalArgumentException( + "compatible flag must equal an empty incompatibility reason set"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java new file mode 100644 index 0000000..1c736b5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Provider-neutral delivery medium. */ +public enum NotificationChannel { + EMAIL, + SLACK +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java new file mode 100644 index 0000000..405e009 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity of one provider leg for a logical recipient. */ +public record NotificationDeliveryId(String value) { + + public NotificationDeliveryId { + value = NotificationIntentId.requireOpaque("deliveryId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java new file mode 100644 index 0000000..0f24b82 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java @@ -0,0 +1,204 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Durable delivery-leg state port; provider I/O is deliberately absent from this contract. */ +public interface NotificationDeliveryStorePort { + + List claimEligible(int maximumClaims, Instant now); + + AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now); + + FinalizationResult finalizeAttempt( + AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now); + + List claimForReconciliation(int maximumClaims, Instant now); + + ReconciliationFinalizationResult finalizeReconciliation( + ReconciliationClaim claim, + NotificationReconciliationPort.ReconciliationOutcome outcome, + Instant now); + + int attachOrphanReceipts(int maximumAttachments, Instant now); + + record ClaimedDelivery( + NotificationDeliveryId deliveryId, + NotificationFrozenPlan plan, + int targetOrdinal, + String claimToken, + long rowVersion, + long admissionGeneration) { + + public ClaimedDelivery { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) { + throw new IllegalArgumentException("target ordinal is outside the frozen plan bound"); + } + claimToken = NotificationIntentId.requireOpaque("claim token", claimToken); + if (rowVersion < 0 || admissionGeneration < 0) { + throw new IllegalArgumentException( + "row version and admission generation must be non-negative"); + } + } + } + + sealed interface AttemptAuthorization permits Authorized, StaleClaim, NotEligible {} + + record Authorized(AuthorizedAttempt attempt) implements AttemptAuthorization { + + public Authorized { + Objects.requireNonNull(attempt, "authorized notification attempt must be non-null"); + } + } + + record StaleClaim(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode) + implements AttemptAuthorization { + + public StaleClaim { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record NotEligible(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode) + implements AttemptAuthorization { + + public NotEligible { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record AuthorizedAttempt( + NotificationDeliveryId deliveryId, + NotificationAttemptId attemptId, + NotificationFrozenPlan plan, + int targetOrdinal, + String claimToken, + String executionToken, + long expectedRowVersion, + long admissionGeneration, + String admissionScopeReference, + Instant absoluteDeadline) { + + public AuthorizedAttempt { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(attemptId, "notification attempt ID must be non-null"); + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) { + throw new IllegalArgumentException("target ordinal is outside the frozen plan bound"); + } + claimToken = NotificationIntentId.requireOpaque("claim token", claimToken); + executionToken = + NotificationIntentId.requireOpaque("attempt execution token", executionToken); + if (claimToken.equals(executionToken)) { + throw new IllegalArgumentException( + "attempt execution token must be distinct from the claim token"); + } + if (expectedRowVersion < 0 || admissionGeneration < 0) { + throw new IllegalArgumentException( + "row version and admission generation must be non-negative"); + } + admissionScopeReference = + NotificationIntentId.requireOpaque("admission scope reference", admissionScopeReference); + Objects.requireNonNull(absoluteDeadline, "absolute attempt deadline must be non-null"); + } + + @Override + public String toString() { + return "AuthorizedAttempt[deliveryId=, attemptId=" + + attemptId + + ", plan=, targetOrdinal=" + + targetOrdinal + + ", claimToken=, executionToken=, expectedRowVersion=" + + expectedRowVersion + + ", admissionGeneration=" + + admissionGeneration + + ", admissionScopeReference=, absoluteDeadline=" + + absoluteDeadline + + "]"; + } + } + + record AttemptFinalization( + ProviderAttemptOutcome providerOutcome, + TerminalState terminalState, + boolean fallbackEligible, + NotificationAdmissionReadinessPort.ParkResult parkResult) { + + public AttemptFinalization { + Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null"); + Objects.requireNonNull(terminalState, "notification terminal state must be non-null"); + Objects.requireNonNull(parkResult, "admission park result must be non-null"); + if (fallbackEligible + && providerOutcome.submissionCertainty() != SubmissionCertainty.DEFINITELY_NOT_APPLIED) { + throw new IllegalArgumentException("fallback is eligible only for DEFINITELY_NOT_APPLIED"); + } + if (terminalState == TerminalState.TERMINAL_INDETERMINATE + && providerOutcome.submissionCertainty() != SubmissionCertainty.INDETERMINATE) { + throw new IllegalArgumentException( + "TERMINAL_INDETERMINATE requires an indeterminate provider outcome"); + } + if (terminalState == TerminalState.PARKED_BINDING + && providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) { + throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition"); + } + } + } + + enum TerminalState { + ACCEPTED, + RETRY_SCHEDULED, + PARKED_BINDING, + TERMINAL_FAILURE, + TERMINAL_INDETERMINATE + } + + enum FinalizationResult { + APPLIED, + LATE_EXACT_APPLIED, + STALE_EXECUTION_TOKEN, + ALREADY_TERMINAL + } + + record ReconciliationClaim( + NotificationDeliveryId deliveryId, + String executionToken, + long expectedRowVersion, + String providerMessageReference, + Instant absoluteDeadline) { + + public ReconciliationClaim { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + executionToken = + NotificationIntentId.requireOpaque("reconciliation execution token", executionToken); + if (expectedRowVersion < 0) { + throw new IllegalArgumentException("reconciliation row version must be non-negative"); + } + providerMessageReference = + NotificationIntentId.requireOpaque( + "provider message reference", providerMessageReference); + Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null"); + } + + @Override + public String toString() { + return "ReconciliationClaim[deliveryId=, executionToken=, " + + "expectedRowVersion=" + + expectedRowVersion + + ", providerMessageReference=, absoluteDeadline=" + + absoluteDeadline + + "]"; + } + } + + enum ReconciliationFinalizationResult { + APPLIED, + LATE_EXACT_APPLIED, + STALE_EXECUTION_TOKEN, + ALREADY_TERMINAL + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java new file mode 100644 index 0000000..3689b39 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; + +/** Requests one bounded cross-tenant dispatch cycle. */ +public record NotificationDispatchCommand(int maximumClaims) implements Command { + + public NotificationDispatchCommand { + if (maximumClaims < 1 || maximumClaims > 100) { + throw new IllegalArgumentException("maximum notification claims must be in 1..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java new file mode 100644 index 0000000..43502a0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.notification; + +/** Bounded non-sensitive aggregate outcome of one dispatch cycle. */ +public record NotificationDispatchResult( + int claimedCount, + int authorizedCount, + int providerCallCount, + int finalizedCount, + int staleClaimCount, + int indeterminateCount, + int parkedCount) { + + public NotificationDispatchResult { + int[] counts = { + claimedCount, + authorizedCount, + providerCallCount, + finalizedCount, + staleClaimCount, + indeterminateCount, + parkedCount + }; + for (int count : counts) { + if (count < 0 || count > 100) { + throw new IllegalArgumentException("notification dispatch counts must be in 0..100"); + } + } + if (authorizedCount > claimedCount + || providerCallCount > authorizedCount + || finalizedCount > providerCallCount + || staleClaimCount > claimedCount + || indeterminateCount > providerCallCount + || parkedCount > providerCallCount) { + throw new IllegalArgumentException("notification dispatch counts are inconsistent"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java new file mode 100644 index 0000000..cfac1be --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java @@ -0,0 +1,191 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.List; +import java.util.Objects; + +/** Coordinates short store transactions around provider I/O for a bounded delivery batch. */ +@RequiresPermission("notification:dispatch") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + sensitiveRead = true, + crossTenantAdmin = true) +public final class NotificationDispatchUseCase + implements CommandUseCase { + + private final NotificationDeliveryStorePort store; + private final NotificationProviderAttemptPort provider; + private final NotificationAdmissionReadinessPort admission; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationDispatchUseCase( + NotificationDeliveryStorePort store, + NotificationProviderAttemptPort provider, + NotificationAdmissionReadinessPort admission, + TransactionPort transactions, + Clock clock) { + this.store = Objects.requireNonNull(store, "notification delivery store must be non-null"); + this.provider = Objects.requireNonNull(provider, "notification provider port must be non-null"); + this.admission = + Objects.requireNonNull(admission, "notification admission port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public NotificationDispatchResult handle(NotificationDispatchCommand command) { + Objects.requireNonNull(command, "notification dispatch command must be non-null"); + List claimed = + List.copyOf( + transactions.inWrite( + () -> store.claimEligible(command.maximumClaims(), clock.instant()))); + if (claimed.size() > command.maximumClaims()) { + throw new IllegalStateException("notification store returned more claims than requested"); + } + + MutableCounts counts = new MutableCounts(claimed.size()); + for (NotificationDeliveryStorePort.ClaimedDelivery delivery : claimed) { + dispatchOne(delivery, counts); + } + return counts.toResult(); + } + + private void dispatchOne( + NotificationDeliveryStorePort.ClaimedDelivery delivery, MutableCounts counts) { + NotificationDeliveryStorePort.AttemptAuthorization authorization = + transactions.inWrite(() -> store.reserveAndAuthorize(delivery, clock.instant())); + if (authorization instanceof NotificationDeliveryStorePort.StaleClaim) { + counts.staleClaims++; + return; + } + if (authorization instanceof NotificationDeliveryStorePort.NotEligible) { + return; + } + + NotificationDeliveryStorePort.AuthorizedAttempt attempt = + ((NotificationDeliveryStorePort.Authorized) authorization).attempt(); + counts.authorized++; + ProviderAttemptOutcome outcome = invokeProvider(attempt); + counts.providerCalls++; + if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) { + counts.indeterminate++; + } + + FinalizationExecution execution = + transactions.inWrite(() -> finalizeInsideTransaction(attempt, outcome)); + boolean applied = + execution.result() == NotificationDeliveryStorePort.FinalizationResult.APPLIED + || execution.result() + == NotificationDeliveryStorePort.FinalizationResult.LATE_EXACT_APPLIED; + if (applied) { + counts.finalized++; + } + if (applied + && execution.finalization().terminalState() + == NotificationDeliveryStorePort.TerminalState.PARKED_BINDING) { + counts.parked++; + } + } + + private ProviderAttemptOutcome invokeProvider( + NotificationDeliveryStorePort.AuthorizedAttempt attempt) { + try { + return Objects.requireNonNull( + provider.attempt(attempt), "provider attempt outcome must be non-null"); + } catch (RuntimeException providerFailure) { + return new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("UNCLASSIFIED_PROVIDER_FAILURE"), + java.util.Optional.empty(), + attempt.executionToken(), + java.util.Optional.empty()); + } + } + + private FinalizationExecution finalizeInsideTransaction( + NotificationDeliveryStorePort.AuthorizedAttempt attempt, ProviderAttemptOutcome outcome) { + NotificationAdmissionReadinessPort.ParkResult parkResult = + NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED; + if (outcome.retryDisposition() == RetryDisposition.PARK_BINDING) { + parkResult = + admission.park( + new NotificationAdmissionReadinessPort.ParkRequest( + attempt.plan().routeId(), + attempt.plan().policy().policyRevision(), + outcome.faultScope(), + attempt.admissionScopeReference(), + attempt.admissionGeneration(), + outcome.reasonCode(), + clock.instant())); + } + + NotificationDeliveryStorePort.AttemptFinalization finalization = + new NotificationDeliveryStorePort.AttemptFinalization( + outcome, terminalState(outcome), fallbackEligible(outcome), parkResult); + NotificationDeliveryStorePort.FinalizationResult result = + Objects.requireNonNull( + store.finalizeAttempt(attempt, finalization, clock.instant()), + "notification attempt finalization result must be non-null"); + return new FinalizationExecution(finalization, result); + } + + private static NotificationDeliveryStorePort.TerminalState terminalState( + ProviderAttemptOutcome outcome) { + if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) { + return NotificationDeliveryStorePort.TerminalState.ACCEPTED; + } + if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) { + return NotificationDeliveryStorePort.TerminalState.TERMINAL_INDETERMINATE; + } + return switch (outcome.retryDisposition()) { + case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED; + case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING; + case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE; + case NOT_APPLICABLE -> + throw new IllegalArgumentException( + "definitely-not-applied outcome requires an explicit disposition"); + }; + } + + private static boolean fallbackEligible(ProviderAttemptOutcome outcome) { + return outcome.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED + && outcome.retryDisposition() == RetryDisposition.TERMINAL; + } + + private record FinalizationExecution( + NotificationDeliveryStorePort.AttemptFinalization finalization, + NotificationDeliveryStorePort.FinalizationResult result) {} + + private static final class MutableCounts { + + private final int claimed; + private int authorized; + private int providerCalls; + private int finalized; + private int staleClaims; + private int indeterminate; + private int parked; + + private MutableCounts(int claimed) { + this.claimed = claimed; + } + + private NotificationDispatchResult toResult() { + return new NotificationDispatchResult( + claimed, authorized, providerCalls, finalized, staleClaims, indeterminate, parked); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java new file mode 100644 index 0000000..a94083a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Historical issuer-key decision retained with accepted signed evidence. */ +public record NotificationEvidenceTrustSnapshot( + String catalogRevision, HistoricalKeyStatus historicalKeyStatus, String issuerKeyDigest) { + + public NotificationEvidenceTrustSnapshot { + catalogRevision = + NotificationIntentId.requireSlug("evidence trust catalog revision", catalogRevision); + Objects.requireNonNull(historicalKeyStatus, "historical evidence key status must be non-null"); + issuerKeyDigest = InitializeNotificationWriterFencesCommand.requireDigest(issuerKeyDigest); + if (historicalKeyStatus == HistoricalKeyStatus.REVOKED) { + throw new IllegalArgumentException("revoked evidence issuer key cannot be accepted"); + } + } + + public enum HistoricalKeyStatus { + ALLOWED, + RETIRING, + REVOKED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java new file mode 100644 index 0000000..9a3dad4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Smallest durable scope affected by a classified attempt failure. */ +public enum NotificationFaultScope { + DELIVERY, + ROUTE_REVISION, + PROVIDER_BINDING, + ACCOUNT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java new file mode 100644 index 0000000..6745e9c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java @@ -0,0 +1,89 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Immutable provider-neutral plan snapshot returned by the application planning boundary. */ +public record NotificationFrozenPlan( + NotificationIntentId intentId, + NotificationKindPolicy policy, + Locale selectedLocale, + NotificationRecipientReference recipient, + NotificationTemplateParameters parameters, + String idempotencyScope, + String sourceOperationId, + Optional tenantReference, + String correlationReference, + Optional causationReference, + Instant notBefore, + Instant expiresAt) { + + public NotificationFrozenPlan { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + Objects.requireNonNull(policy, "notification kind policy must be non-null"); + selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale); + Objects.requireNonNull(recipient, "notification recipient must be non-null"); + Objects.requireNonNull(parameters, "notification template parameters must be non-null"); + idempotencyScope = + NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope); + sourceOperationId = + NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId); + Objects.requireNonNull(tenantReference, "tenant reference container must be non-null"); + correlationReference = + NotificationIntentId.requireOpaque( + "notification correlation reference", correlationReference); + Objects.requireNonNull(causationReference, "causation reference container must be non-null"); + Objects.requireNonNull(notBefore, "notification not-before time must be non-null"); + Objects.requireNonNull(expiresAt, "notification expiry time must be non-null"); + if (recipient.channel() != policy.channel()) { + throw new IllegalArgumentException("recipient channel must match notification kind channel"); + } + if (!expiresAt.isAfter(notBefore)) { + throw new IllegalArgumentException("notification expiry must be after not-before"); + } + } + + public static NotificationFrozenPlan from(NotificationIntentDraft draft, Locale selectedLocale) { + Objects.requireNonNull(draft, "notification intent draft must be non-null"); + return new NotificationFrozenPlan( + draft.intentId(), + draft.policy(), + selectedLocale, + draft.recipient(), + draft.parameters(), + draft.idempotencyScope(), + draft.sourceOperationId(), + draft.tenantReference(), + draft.correlationReference(), + draft.causationReference(), + draft.notBefore(), + draft.expiresAt()); + } + + public NotificationMode mode() { + return policy.mode(); + } + + public NotificationRouteId routeId() { + return policy.routeId(); + } + + @Override + public String toString() { + return "NotificationFrozenPlan[intentId=" + + intentId + + ", kindId=" + + policy.kindId() + + ", policyRevision=" + + policy.policyRevision() + + ", selectedLocale=" + + selectedLocale.toLanguageTag() + + ", recipient=, parameters=, context=, notBefore=" + + notBefore + + ", expiresAt=" + + expiresAt + + "]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java new file mode 100644 index 0000000..4346c5f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.notification; + +/** + * Appends a frozen intent to durable storage in the caller's current transaction. Implementations + * must not open an independent transaction. + */ +@FunctionalInterface +public interface NotificationIntentAppendPort { + + NotificationAppendResult append(NotificationFrozenPlan plan); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java new file mode 100644 index 0000000..59356cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java @@ -0,0 +1,84 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Feature-policy output containing one logical recipient and no provider or transport types. */ +public record NotificationIntentDraft( + NotificationIntentId intentId, + NotificationKindPolicy policy, + Locale requestedLocale, + NotificationRecipientReference recipient, + NotificationTemplateParameters parameters, + String idempotencyScope, + String sourceOperationId, + Optional tenantReference, + String correlationReference, + Optional causationReference, + Instant notBefore, + Instant expiresAt) { + + public NotificationIntentDraft { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + Objects.requireNonNull(policy, "notification kind policy must be non-null"); + requestedLocale = requireLocale("requested locale", requestedLocale); + Objects.requireNonNull(recipient, "notification recipient must be non-null"); + Objects.requireNonNull(parameters, "notification template parameters must be non-null"); + if (recipient.channel() != policy.channel()) { + throw new IllegalArgumentException("recipient channel must match notification kind channel"); + } + idempotencyScope = + NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope); + sourceOperationId = + NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId); + tenantReference = requireOptionalOpaque("tenant reference", tenantReference); + correlationReference = + NotificationIntentId.requireOpaque( + "notification correlation reference", correlationReference); + causationReference = requireOptionalOpaque("causation reference", causationReference); + Objects.requireNonNull(notBefore, "notification not-before time must be non-null"); + Objects.requireNonNull(expiresAt, "notification expiry time must be non-null"); + Duration lifetime = Duration.between(notBefore, expiresAt); + if (lifetime.isZero() + || lifetime.isNegative() + || lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) { + throw new IllegalArgumentException( + "notification expiry must be after not-before and within the policy retry horizon"); + } + } + + static Locale requireLocale(String field, Locale locale) { + Objects.requireNonNull(locale, field + " must be non-null"); + String languageTag = locale.toLanguageTag(); + if (locale.equals(Locale.ROOT) + || languageTag.equals("und") + || languageTag.isBlank() + || languageTag.length() > 35) { + throw new IllegalArgumentException(field + " must be an explicit bounded locale"); + } + return Locale.forLanguageTag(languageTag); + } + + private static Optional requireOptionalOpaque(String field, Optional reference) { + Objects.requireNonNull(reference, field + " container must be non-null"); + return reference.map(value -> NotificationIntentId.requireOpaque(field, value)); + } + + @Override + public String toString() { + return "NotificationIntentDraft[intentId=" + + intentId + + ", kindId=" + + policy.kindId() + + ", requestedLocale=" + + requestedLocale.toLanguageTag() + + ", recipient=, parameters=, context=, notBefore=" + + notBefore + + ", expiresAt=" + + expiresAt + + "]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java new file mode 100644 index 0000000..6c1c3c4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity of one logical business notification. */ +public record NotificationIntentId(String value) { + + public NotificationIntentId { + value = requireOpaque("intentId", value); + } + + static String requireOpaque(String field, String value) { + if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) { + throw new IllegalArgumentException( + field + " must contain 1..128 opaque identifier characters"); + } + return value; + } + + static String requireSlug(String field, String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java new file mode 100644 index 0000000..3aed1f8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Closed-catalog notification business kind. */ +public record NotificationKindId(String value) { + + public NotificationKindId { + value = NotificationIntentId.requireSlug("kindId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java new file mode 100644 index 0000000..05429fa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java @@ -0,0 +1,124 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.util.Objects; + +/** + * Code-owned notification kind catalog row. Runtime configuration may assert these values and lower + * resource concurrency, but cannot replace semantic fields in this record. + */ +public record NotificationKindPolicy( + NotificationKindId kindId, + NotificationChannel channel, + NotificationRouteId routeId, + NotificationTemplateRef templateRef, + NotificationMode mode, + NotificationAdmissionClass admissionClass, + NotificationRouteStrategy routeStrategy, + ConsentCheckMode consentCheckMode, + int policyRevision, + int maxTargetsPerRecipient, + int maxPhysicalAttemptsPerDelivery, + int maxFallbackActivations, + int maxReconcileCalls, + int maxTotalProviderCallsPerIntent, + Duration maxElapsedRetryHorizon) { + + private static final int MAXIMUM_TARGETS = 16; + private static final int MAXIMUM_ATTEMPTS_PER_DELIVERY = 10; + private static final int MAXIMUM_PROVIDER_CALLS = 64; + private static final Duration MAXIMUM_RETRY_HORIZON = Duration.ofDays(30); + + public NotificationKindPolicy { + Objects.requireNonNull(kindId, "notification kind must be non-null"); + Objects.requireNonNull(channel, "notification channel must be non-null"); + Objects.requireNonNull(routeId, "notification route must be non-null"); + Objects.requireNonNull(templateRef, "notification template must be non-null"); + Objects.requireNonNull(mode, "notification mode must be non-null"); + Objects.requireNonNull(admissionClass, "notification admission class must be non-null"); + Objects.requireNonNull(routeStrategy, "notification route strategy must be non-null"); + Objects.requireNonNull(consentCheckMode, "consent check mode must be non-null"); + Objects.requireNonNull(maxElapsedRetryHorizon, "maximum retry horizon must be non-null"); + if (policyRevision < 1 || policyRevision > 1_000_000) { + throw new IllegalArgumentException("policy revision must be in 1..1000000"); + } + if (maxTargetsPerRecipient < 1 || maxTargetsPerRecipient > MAXIMUM_TARGETS) { + throw new IllegalArgumentException("maximum targets per recipient must be in 1..16"); + } + if (maxPhysicalAttemptsPerDelivery < 1 + || maxPhysicalAttemptsPerDelivery > MAXIMUM_ATTEMPTS_PER_DELIVERY) { + throw new IllegalArgumentException("maximum physical attempts per delivery must be in 1..10"); + } + if (maxFallbackActivations < 0 || maxFallbackActivations >= MAXIMUM_TARGETS) { + throw new IllegalArgumentException("maximum fallback activations must be in 0..15"); + } + if (maxReconcileCalls < 0 || maxReconcileCalls > 10) { + throw new IllegalArgumentException("maximum reconcile calls must be in 0..10"); + } + if (maxTotalProviderCallsPerIntent < 1 + || maxTotalProviderCallsPerIntent > MAXIMUM_PROVIDER_CALLS) { + throw new IllegalArgumentException("maximum total provider calls must be in 1..64"); + } + if (maxElapsedRetryHorizon.isZero() + || maxElapsedRetryHorizon.isNegative() + || maxElapsedRetryHorizon.compareTo(MAXIMUM_RETRY_HORIZON) > 0) { + throw new IllegalArgumentException( + "maximum retry horizon must be positive and at most 30 days"); + } + if (admissionClass == NotificationAdmissionClass.SECURITY_CRITICAL + && mode == NotificationMode.BEST_EFFORT_INLINE) { + throw new IllegalArgumentException( + "SECURITY_CRITICAL notification kind cannot use BEST_EFFORT_INLINE"); + } + validateStrategy(routeStrategy, maxTargetsPerRecipient, maxFallbackActivations); + if (mode == NotificationMode.BEST_EFFORT_INLINE + && (maxPhysicalAttemptsPerDelivery != 1 || maxReconcileCalls != 0)) { + throw new IllegalArgumentException( + "BEST_EFFORT_INLINE permits one attempt per target and no reconciliation"); + } + long worstCaseProviderCalls = + Math.addExact( + Math.multiplyExact( + (long) maxTargetsPerRecipient, (long) maxPhysicalAttemptsPerDelivery), + maxReconcileCalls); + if (worstCaseProviderCalls > maxTotalProviderCallsPerIntent) { + throw new IllegalArgumentException( + "worst-case provider calls exceed maximum total provider calls per intent"); + } + } + + public NotificationKindPolicy assertRuntimeExpectation( + NotificationMode expectedMode, NotificationAdmissionClass expectedAdmissionClass) { + Objects.requireNonNull(expectedMode, "expected notification mode must be non-null"); + Objects.requireNonNull( + expectedAdmissionClass, "expected notification admission class must be non-null"); + if (mode != expectedMode) { + throw new IllegalStateException( + "runtime expected mode " + expectedMode + " does not match code-owned mode " + mode); + } + if (admissionClass != expectedAdmissionClass) { + throw new IllegalStateException( + "runtime expected admission " + + expectedAdmissionClass + + " does not match code-owned admission " + + admissionClass); + } + return this; + } + + private static void validateStrategy( + NotificationRouteStrategy strategy, int maximumTargets, int maximumFallbacks) { + if (strategy == NotificationRouteStrategy.SINGLE + && (maximumTargets != 1 || maximumFallbacks != 0)) { + throw new IllegalArgumentException("SINGLE requires one target and zero fallbacks"); + } + if (strategy == NotificationRouteStrategy.FAN_OUT_ALL && maximumFallbacks != 0) { + throw new IllegalArgumentException("FAN_OUT_ALL cannot activate fallback targets"); + } + if (strategy == NotificationRouteStrategy.ORDERED_FALLBACK + && (maximumTargets < 2 || maximumFallbacks < 1 || maximumFallbacks > maximumTargets - 1)) { + throw new IllegalArgumentException( + "ORDERED_FALLBACK requires 2..16 targets and 1..targetCount-1 fallbacks"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java new file mode 100644 index 0000000..6bbb029 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java @@ -0,0 +1,103 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** Root-committed acquire or release of one bounded legacy writer permit. */ +public record NotificationLegacyWriterPermitCommand( + Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String transportProfileId, + String permitToken, + String holderReference, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Optional wireBudget) + implements Command { + + private static final Duration MAXIMUM_WIRE_BUDGET = Duration.ofSeconds(30); + + public NotificationLegacyWriterPermitCommand { + Objects.requireNonNull(action, "legacy writer permit action must be non-null"); + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (expectedGeneration < 0) { + throw new IllegalArgumentException("expected writer generation must be non-negative"); + } + transportProfileId = + NotificationIntentId.requireSlug("legacy transport profile ID", transportProfileId); + permitToken = NotificationIntentId.requireOpaque("legacy writer permit token", permitToken); + holderReference = + NotificationIntentId.requireOpaque("legacy writer permit holder", holderReference); + operationToken = + NotificationIntentId.requireOpaque("legacy writer permit operation token", operationToken); + actorReference = + NotificationIntentId.requireOpaque("legacy writer permit actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(wireBudget, "wire budget container must be non-null"); + if (action == Action.ACQUIRE) { + Duration budget = + wireBudget.orElseThrow( + () -> new IllegalArgumentException("legacy permit acquire requires a wire budget")); + if (budget.isZero() || budget.isNegative() || budget.compareTo(MAXIMUM_WIRE_BUDGET) > 0) { + throw new IllegalArgumentException( + "legacy writer wire budget must be positive and at most 30 seconds"); + } + } else if (wireBudget.isPresent()) { + throw new IllegalArgumentException("legacy permit release must not carry a wire budget"); + } + } + + public static NotificationLegacyWriterPermitCommand acquire( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String transportProfileId, + String permitToken, + String holderReference, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Duration wireBudget) { + return new NotificationLegacyWriterPermitCommand( + Action.ACQUIRE, + route, + expectedGeneration, + transportProfileId, + permitToken, + holderReference, + operationToken, + actorReference, + reasonCode, + Optional.of(wireBudget)); + } + + public static NotificationLegacyWriterPermitCommand release( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String transportProfileId, + String permitToken, + String holderReference, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode) { + return new NotificationLegacyWriterPermitCommand( + Action.RELEASE, + route, + expectedGeneration, + transportProfileId, + permitToken, + holderReference, + operationToken, + actorReference, + reasonCode, + Optional.empty()); + } + + public enum Action { + ACQUIRE, + RELEASE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java new file mode 100644 index 0000000..7547674 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java @@ -0,0 +1,45 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Physically committed permit facts used by the legacy wrapper's monotonic wire deadline guard. */ +public record NotificationLegacyWriterPermitResult( + Status status, + String permitToken, + Optional acquiredAt, + Optional wireDeadline, + Optional expiresAt) { + + public NotificationLegacyWriterPermitResult { + Objects.requireNonNull(status, "legacy writer permit status must be non-null"); + permitToken = NotificationIntentId.requireOpaque("legacy writer permit token", permitToken); + Objects.requireNonNull(acquiredAt, "permit acquired-at container must be non-null"); + Objects.requireNonNull(wireDeadline, "permit wire-deadline container must be non-null"); + Objects.requireNonNull(expiresAt, "permit expiry container must be non-null"); + if (status == Status.ACQUIRED) { + Instant acquired = + acquiredAt.orElseThrow( + () -> new IllegalArgumentException("acquired permit requires DB acquired-at")); + Instant deadline = + wireDeadline.orElseThrow( + () -> new IllegalArgumentException("acquired permit requires wire deadline")); + Instant expiry = + expiresAt.orElseThrow( + () -> new IllegalArgumentException("acquired permit requires expiry")); + if (deadline.isBefore(acquired) || expiry.isBefore(deadline)) { + throw new IllegalArgumentException( + "permit timestamps must satisfy acquiredAt <= wireDeadline <= expiresAt"); + } + } else if (acquiredAt.isPresent() || wireDeadline.isPresent() || expiresAt.isPresent()) { + throw new IllegalArgumentException("non-acquired permit result must not expose wire times"); + } + } + + public enum Status { + ACQUIRED, + RELEASED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java new file mode 100644 index 0000000..d59eaba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java @@ -0,0 +1,59 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Root-commits bounded legacy permit acquire/release before any caller provider I/O. */ +@RequiresPermission("notification:cutover-admit") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class NotificationLegacyWriterPermitUseCase + implements CommandUseCase< + NotificationLegacyWriterPermitCommand, NotificationLegacyWriterPermitResult> { + + private final NotificationWriterRouteSet routes; + private final NotificationWriterCutoverPort cutover; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationLegacyWriterPermitUseCase( + NotificationWriterRouteSet routes, + NotificationWriterCutoverPort cutover, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.cutover = + Objects.requireNonNull(cutover, "notification writer cutover port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public NotificationLegacyWriterPermitResult handle( + NotificationLegacyWriterPermitCommand command) { + Objects.requireNonNull(command, "legacy writer permit command must be non-null"); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(command.route()); + NotificationWriterRouteSet.TransportProfile profile = + route.requireProfile(command.transportProfileId()); + if (command.action() == NotificationLegacyWriterPermitCommand.Action.ACQUIRE + && !profile.activeAdmissionProfile()) { + throw new IllegalArgumentException( + "new legacy permit acquire requires the active admission transport profile"); + } + return transactions.inRootWrite( + () -> + command.action() == NotificationLegacyWriterPermitCommand.Action.ACQUIRE + ? cutover.acquireLegacyPermit(command, route, clock.instant()) + : cutover.releaseLegacyPermit(command, route, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java new file mode 100644 index 0000000..cb109f0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; + +/** Bounded retention and redaction mutation request. */ +public record NotificationMaintenanceCommand( + int maximumExpiredIntents, int maximumPayloadRedactions, int maximumExpiredReceipts) + implements Command { + + public NotificationMaintenanceCommand { + if (maximumExpiredIntents < 0 + || maximumPayloadRedactions < 0 + || maximumExpiredReceipts < 0 + || maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts < 1 + || maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts > 100) { + throw new IllegalArgumentException( + "notification maintenance total mutation bound must be in 1..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java new file mode 100644 index 0000000..6e421b6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.notification; + +/** Bounded non-sensitive maintenance outcome. */ +public record NotificationMaintenanceResult( + int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) { + + public NotificationMaintenanceResult { + int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount; + if (expiredIntentCount < 0 + || redactedPayloadCount < 0 + || expiredReceiptCount < 0 + || total > 100) { + throw new IllegalArgumentException("notification maintenance result must total 0..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java new file mode 100644 index 0000000..738ca31 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Performs one bounded local retention/redaction mutation batch. */ +@FunctionalInterface +public interface NotificationMaintenanceStorePort { + + MutationResult maintain(NotificationMaintenanceCommand command, Instant now); + + record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) { + + public MutationResult { + int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount; + if (expiredIntentCount < 0 + || redactedPayloadCount < 0 + || expiredReceiptCount < 0 + || total > 100) { + throw new IllegalArgumentException("notification maintenance mutations must total 0..100"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java new file mode 100644 index 0000000..e87e79b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java @@ -0,0 +1,44 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Runs one bounded notification retention/redaction mutation in a short write transaction. */ +@RequiresPermission("notification:maintain") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class NotificationMaintenanceUseCase + implements CommandUseCase { + + private final NotificationMaintenanceStorePort store; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationMaintenanceUseCase( + NotificationMaintenanceStorePort store, TransactionPort transactions, Clock clock) { + this.store = Objects.requireNonNull(store, "notification maintenance store must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public NotificationMaintenanceResult handle(NotificationMaintenanceCommand command) { + Objects.requireNonNull(command, "notification maintenance command must be non-null"); + NotificationMaintenanceStorePort.MutationResult mutation = + transactions.inWrite(() -> store.maintain(command, clock.instant())); + return new NotificationMaintenanceResult( + mutation.expiredIntentCount(), + mutation.redactedPayloadCount(), + mutation.expiredReceiptCount()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java new file mode 100644 index 0000000..d9bb288 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Code-owned delivery durability contract. */ +public enum NotificationMode { + BEST_EFFORT_INLINE, + DURABLE_ASYNC +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java new file mode 100644 index 0000000..df256e3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Bounded non-sensitive operational aggregate returned through the application query boundary. */ +public record NotificationOperationsSnapshot( + Instant observedAt, + long pendingIntentCount, + long parkedDeliveryCount, + long orphanReceiptCount, + long activeLegacyPermitCount, + List writerRoutes) { + + private static final long MAXIMUM_COUNT = 1_000_000_000L; + + public NotificationOperationsSnapshot { + Objects.requireNonNull(observedAt, "notification snapshot time must be non-null"); + validateCount(pendingIntentCount); + validateCount(parkedDeliveryCount); + validateCount(orphanReceiptCount); + validateCount(activeLegacyPermitCount); + writerRoutes = + List.copyOf( + Objects.requireNonNull(writerRoutes, "writer route snapshots must be non-null")); + if (writerRoutes.size() > 100) { + throw new IllegalArgumentException("writer route snapshot exceeds 100 entries"); + } + } + + private static void validateCount(long count) { + if (count < 0 || count > MAXIMUM_COUNT) { + throw new IllegalArgumentException("notification operation count is outside 0..1000000000"); + } + } + + public record RouteWriterStatus( + NotificationRouteId routeId, + int routeRevision, + NotificationWriterOwnership owner, + long generation, + boolean draining) { + + public RouteWriterStatus { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + Objects.requireNonNull(owner, "notification writer owner must be non-null"); + if (routeRevision < 1 || generation < 0) { + throw new IllegalArgumentException( + "writer route revision must be positive and generation non-negative"); + } + if (draining && owner != NotificationWriterOwnership.LEGACY) { + throw new IllegalArgumentException("only LEGACY writer ownership may be draining"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java new file mode 100644 index 0000000..8c74e79 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Loads bounded non-sensitive operational aggregates from the durable store. */ +@FunctionalInterface +public interface NotificationOperationsSnapshotPort { + + NotificationOperationsSnapshot load(NotificationOperationsSnapshotQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java new file mode 100644 index 0000000..ac11509 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.query.Query; + +/** Requests at most a bounded number of route writer status rows. */ +public record NotificationOperationsSnapshotQuery(int maximumRoutes) implements Query { + + public NotificationOperationsSnapshotQuery { + if (maximumRoutes < 1 || maximumRoutes > 100) { + throw new IllegalArgumentException("maximum notification routes must be in 1..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java new file mode 100644 index 0000000..3869fb8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** Application query boundary for bounded notification operations visibility. */ +@RequiresPermission("notification:observe") +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY, + crossTenantAdmin = true) +public final class NotificationOperationsSnapshotUseCase + implements QueryUseCase { + + private final NotificationOperationsSnapshotPort snapshots; + private final TransactionPort transactions; + + public NotificationOperationsSnapshotUseCase( + NotificationOperationsSnapshotPort snapshots, TransactionPort transactions) { + this.snapshots = + Objects.requireNonNull(snapshots, "notification operations snapshot port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + } + + @Override + public NotificationOperationsSnapshot handle(NotificationOperationsSnapshotQuery query) { + Objects.requireNonNull(query, "notification operations snapshot query must be non-null"); + return transactions.inRead(() -> snapshots.load(query)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java new file mode 100644 index 0000000..95a6d47 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Resolves a feature-owned draft into a provider-neutral immutable application plan. */ +@FunctionalInterface +public interface NotificationPlanPort { + + NotificationPlanningResult plan(NotificationIntentDraft draft); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java new file mode 100644 index 0000000..28ee691 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Closed result of compiling a feature draft without exposing adapter binding types. */ +public sealed interface NotificationPlanningResult + permits NotificationPlanningResult.Planned, + NotificationPlanningResult.Rejected, + NotificationPlanningResult.CapabilityUnavailable { + + record Planned(NotificationFrozenPlan plan) implements NotificationPlanningResult { + + public Planned { + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + } + } + + record Rejected(NotificationReasonCode reasonCode) implements NotificationPlanningResult { + + public Rejected { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record CapabilityUnavailable(NotificationReasonCode reasonCode) + implements NotificationPlanningResult { + + public CapabilityUnavailable { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java new file mode 100644 index 0000000..9309497 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Performs one previously authorized provider call and returns only a classified outcome. */ +@FunctionalInterface +public interface NotificationProviderAttemptPort { + + ProviderAttemptOutcome attempt(NotificationDeliveryStorePort.AuthorizedAttempt attempt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java new file mode 100644 index 0000000..eedf4d8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** Provider-neutral capability facts consumed by the pure compatibility validator. */ +public record NotificationProviderCapabilityDescriptor( + String capabilityReference, + NotificationChannel channel, + Set supportedModes, + boolean receiptSupported, + boolean reconciliationSupported, + boolean hiddenRetriesControlled, + int maximumTargets, + int maximumPayloadBytes) { + + public NotificationProviderCapabilityDescriptor { + capabilityReference = + NotificationIntentId.requireOpaque( + "notification provider capability reference", capabilityReference); + Objects.requireNonNull(channel, "notification provider channel must be non-null"); + Objects.requireNonNull(supportedModes, "notification provider modes must be non-null"); + EnumSet modes = + supportedModes.isEmpty() + ? EnumSet.noneOf(NotificationMode.class) + : EnumSet.copyOf(supportedModes); + if (modes.isEmpty()) { + throw new IllegalArgumentException("notification provider must support at least one mode"); + } + supportedModes = Collections.unmodifiableSet(modes); + if (maximumTargets < 1 || maximumTargets > 16) { + throw new IllegalArgumentException("notification provider maximum targets must be in 1..16"); + } + if (maximumPayloadBytes < 1 || maximumPayloadBytes > 10_000_000) { + throw new IllegalArgumentException( + "notification provider maximum payload must be in 1..10000000 bytes"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java new file mode 100644 index 0000000..11322bf --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.notification; + +/** Bounded stable operational reason code; never a provider error body or SDK exception message. */ +public record NotificationReasonCode(String value) { + + public NotificationReasonCode { + if (value == null || !value.matches("[A-Z][A-Z0-9_]{0,63}")) { + throw new IllegalArgumentException( + "notification reason code must match [A-Z][A-Z0-9_]{0,63}"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java new file mode 100644 index 0000000..dd87517 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity used to deduplicate one normalized provider receipt event. */ +public record NotificationReceiptEventId(String value) { + + public NotificationReceiptEventId { + value = NotificationIntentId.requireOpaque("receiptEventId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java new file mode 100644 index 0000000..7faa18d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** One normalized, immutable provider feedback fact. */ +public record NotificationReceiptFact( + Type type, BounceClass bounceClass, NotificationReasonCode reasonCode, Instant occurredAt) { + + public NotificationReceiptFact { + Objects.requireNonNull(type, "notification receipt type must be non-null"); + Objects.requireNonNull(bounceClass, "notification bounce class must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(occurredAt, "notification receipt occurrence time must be non-null"); + if ((type == Type.BOUNCE) != (bounceClass != BounceClass.NONE)) { + throw new IllegalArgumentException( + "BOUNCE requires HARD or SOFT classification and other facts require NONE"); + } + } + + public enum Type { + SEND, + DELIVERY, + BOUNCE, + COMPLAINT, + DELIVERY_DELAY + } + + public enum BounceClass { + NONE, + SOFT, + HARD + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java new file mode 100644 index 0000000..bae4f33 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** Authenticated normalized receipt-ingress capabilities for one channel. */ +public record NotificationReceiptIngressCapabilityDescriptor( + NotificationChannel channel, + boolean enabled, + boolean authenticated, + Set supportedFactTypes) { + + public NotificationReceiptIngressCapabilityDescriptor { + Objects.requireNonNull(channel, "notification receipt ingress channel must be non-null"); + Objects.requireNonNull( + supportedFactTypes, "notification receipt ingress fact types must be non-null"); + EnumSet factTypes = + supportedFactTypes.isEmpty() + ? EnumSet.noneOf(NotificationReceiptFact.Type.class) + : EnumSet.copyOf(supportedFactTypes); + supportedFactTypes = Collections.unmodifiableSet(factTypes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java new file mode 100644 index 0000000..079d337 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java @@ -0,0 +1,70 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Order-independent orthogonal projection derived only from immutable receipt facts. */ +public record NotificationReceiptProjection( + boolean submissionAccepted, + boolean delivered, + boolean softBounced, + boolean hardBounced, + boolean complained, + boolean deliveryDelayed, + Instant latestFactAt, + int factCount) { + + public NotificationReceiptProjection { + Objects.requireNonNull(latestFactAt, "latest receipt fact time must be non-null"); + if (factCount < 1 || factCount > 100) { + throw new IllegalArgumentException("receipt projection fact count must be in 1..100"); + } + if ((delivered || softBounced || hardBounced || complained || deliveryDelayed) + && !submissionAccepted) { + throw new IllegalArgumentException( + "delivery feedback cannot erase or contradict provider acceptance"); + } + } + + public static NotificationReceiptProjection reduce(List facts) { + Objects.requireNonNull(facts, "notification receipt facts must be non-null"); + List immutableFacts = List.copyOf(facts); + if (immutableFacts.isEmpty() || immutableFacts.size() > 100) { + throw new IllegalArgumentException("notification receipt facts must contain 1..100 entries"); + } + + boolean accepted = false; + boolean delivered = false; + boolean softBounced = false; + boolean hardBounced = false; + boolean complained = false; + boolean delayed = false; + Instant latest = Instant.MIN; + for (NotificationReceiptFact fact : immutableFacts) { + Objects.requireNonNull(fact, "notification receipt fact must be non-null"); + accepted = true; + delivered |= fact.type() == NotificationReceiptFact.Type.DELIVERY; + softBounced |= + fact.type() == NotificationReceiptFact.Type.BOUNCE + && fact.bounceClass() == NotificationReceiptFact.BounceClass.SOFT; + hardBounced |= + fact.type() == NotificationReceiptFact.Type.BOUNCE + && fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD; + complained |= fact.type() == NotificationReceiptFact.Type.COMPLAINT; + delayed |= fact.type() == NotificationReceiptFact.Type.DELIVERY_DELAY; + if (fact.occurredAt().isAfter(latest)) { + latest = fact.occurredAt(); + } + } + return new NotificationReceiptProjection( + accepted, + delivered, + softBounced, + hardBounced, + complained, + delayed, + latest, + immutableFacts.size()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java new file mode 100644 index 0000000..18269be --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java @@ -0,0 +1,52 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; + +/** Durable receipt inbox and delivery projection mutation boundary. */ +public interface NotificationReceiptStorePort { + + AppendResult appendIfAbsent(NormalizedNotificationReceiptCommand command); + + void saveProjection(NotificationDeliveryId deliveryId, NotificationReceiptProjection projection); + + sealed interface AppendResult permits Appended, Duplicate {} + + record Appended(ReceiptAggregate aggregate) implements AppendResult { + + public Appended { + Objects.requireNonNull(aggregate, "notification receipt aggregate must be non-null"); + } + } + + record Duplicate(NotificationReceiptProjection projection) implements AppendResult { + + public Duplicate { + Objects.requireNonNull(projection, "notification receipt projection must be non-null"); + } + } + + record ReceiptAggregate( + NotificationDeliveryId deliveryId, + NotificationRecipientReference recipient, + List facts) { + + public ReceiptAggregate { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(recipient, "notification recipient reference must be non-null"); + facts = + List.copyOf(Objects.requireNonNull(facts, "notification receipt facts must be non-null")); + if (facts.isEmpty() || facts.size() > 100) { + throw new IllegalArgumentException( + "notification receipt aggregate must contain 1..100 facts"); + } + } + + @Override + public String toString() { + return "ReceiptAggregate[deliveryId=, recipient=, factCount=" + + facts.size() + + "]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java new file mode 100644 index 0000000..3b18c23 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Channel-typed opaque recipient reference; raw addresses are forbidden at this boundary. */ +public sealed interface NotificationRecipientReference + permits EmailRecipientReference, SlackAudienceReference { + + NotificationChannel channel(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java new file mode 100644 index 0000000..ebd3207 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Performs one bounded provider reconciliation outside any database transaction. */ +@FunctionalInterface +public interface NotificationReconciliationPort { + + ReconciliationOutcome reconcile(NotificationDeliveryStorePort.ReconciliationClaim claim); + + record ReconciliationOutcome( + SubmissionCertainty submissionCertainty, NotificationReasonCode reasonCode) { + + public ReconciliationOutcome { + Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java new file mode 100644 index 0000000..eff18bd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java @@ -0,0 +1,71 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; + +/** Closed result union for a notification request; append is not delivery success. */ +public sealed interface NotificationRequestResult + permits NotificationRequestResult.InlineCompleted, + NotificationRequestResult.AppendedDurably, + NotificationRequestResult.DuplicateExistingIntent, + NotificationRequestResult.RejectedByBusinessPolicy, + NotificationRequestResult.RejectedInvalidRequest, + NotificationRequestResult.CapabilityUnavailable { + + record InlineCompleted(NotificationIntentId intentId, List outcomes) + implements NotificationRequestResult { + + public InlineCompleted { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + Objects.requireNonNull(outcomes, "inline outcomes must be non-null"); + outcomes = List.copyOf(outcomes); + if (outcomes.isEmpty() || outcomes.size() > 16) { + throw new IllegalArgumentException("inline outcomes must contain 1..16 targets"); + } + long distinctOrdinals = + outcomes.stream().map(TargetAttemptOutcome::targetOrdinal).distinct().count(); + if (distinctOrdinals != outcomes.size()) { + throw new IllegalArgumentException("inline target ordinals must be unique"); + } + } + } + + record AppendedDurably(NotificationIntentId intentId) implements NotificationRequestResult { + + public AppendedDurably { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record DuplicateExistingIntent(NotificationIntentId intentId) + implements NotificationRequestResult { + + public DuplicateExistingIntent { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record RejectedByBusinessPolicy(NotificationReasonCode reasonCode) + implements NotificationRequestResult { + + public RejectedByBusinessPolicy { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record RejectedInvalidRequest(NotificationReasonCode reasonCode) + implements NotificationRequestResult { + + public RejectedInvalidRequest { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record CapabilityUnavailable(NotificationReasonCode reasonCode) + implements NotificationRequestResult { + + public CapabilityUnavailable { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java new file mode 100644 index 0000000..bd15386 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Closed-catalog logical technical route; never a provider or endpoint identifier. */ +public record NotificationRouteId(String value) { + + public NotificationRouteId { + value = NotificationIntentId.requireSlug("routeId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java new file mode 100644 index 0000000..c7be861 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Closed provider-leg expansion strategy for one logical recipient. */ +public enum NotificationRouteStrategy { + SINGLE, + FAN_OUT_ALL, + ORDERED_FALLBACK +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java new file mode 100644 index 0000000..969287d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java @@ -0,0 +1,281 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; + +/** + * Bounded immutable signed-evidence header; cryptographic verification belongs to verifier ports. + */ +public final class NotificationSignedEvidenceHeader { + + private final String canonicalProfile; + private final byte[] canonicalPayload; + private final byte[] signature; + private final String signatureAlgorithm; + private final String issuerKeyId; + private final byte[] issuerPublicKeySpki; + private final String issuerPublicKeyDigest; + private final NotificationEvidenceTrustSnapshot trustSnapshot; + private final Instant issuedAt; + private final Instant expiresAt; + private final Duration allowedClockSkew; + private final Duration acceptanceMargin; + private final String environmentId; + private final String databaseId; + private final String artifactId; + private final String consumerInventoryId; + private final String providerCallLedgerId; + private final String providerCallLedgerSnapshot; + private final int childCount; + private final String childSetDigest; + + public NotificationSignedEvidenceHeader( + String canonicalProfile, + byte[] canonicalPayload, + byte[] signature, + String signatureAlgorithm, + String issuerKeyId, + byte[] issuerPublicKeySpki, + String issuerPublicKeyDigest, + NotificationEvidenceTrustSnapshot trustSnapshot, + Instant issuedAt, + Instant expiresAt, + Duration allowedClockSkew, + Duration acceptanceMargin, + String environmentId, + String databaseId, + String artifactId, + String consumerInventoryId, + String providerCallLedgerId, + String providerCallLedgerSnapshot, + int childCount, + String childSetDigest) { + this.canonicalProfile = + NotificationIntentId.requireSlug("signed evidence canonical profile", canonicalProfile); + this.canonicalPayload = copyBounded("canonical evidence payload", canonicalPayload, 1, 65_536); + this.signature = copyBounded("evidence signature", signature, 64, 128); + if (!"Ed25519".equals(signatureAlgorithm)) { + throw new IllegalArgumentException("signed evidence algorithm must be Ed25519"); + } + this.signatureAlgorithm = signatureAlgorithm; + this.issuerKeyId = NotificationIntentId.requireOpaque("evidence issuer key ID", issuerKeyId); + this.issuerPublicKeySpki = + copyBounded("evidence issuer public-key SPKI", issuerPublicKeySpki, 32, 1_024); + this.issuerPublicKeyDigest = + InitializeNotificationWriterFencesCommand.requireDigest(issuerPublicKeyDigest); + this.trustSnapshot = + Objects.requireNonNull(trustSnapshot, "evidence trust snapshot must be non-null"); + if (!this.issuerPublicKeyDigest.equals(trustSnapshot.issuerKeyDigest())) { + throw new IllegalArgumentException("evidence issuer key digest must match trust snapshot"); + } + this.issuedAt = Objects.requireNonNull(issuedAt, "evidence issued-at must be non-null"); + this.expiresAt = Objects.requireNonNull(expiresAt, "evidence expires-at must be non-null"); + this.allowedClockSkew = + Objects.requireNonNull(allowedClockSkew, "evidence allowed clock skew must be non-null"); + this.acceptanceMargin = + Objects.requireNonNull(acceptanceMargin, "evidence acceptance margin must be non-null"); + if (!expiresAt.isAfter(issuedAt) + || allowedClockSkew.isNegative() + || allowedClockSkew.compareTo(Duration.ofMinutes(5)) > 0 + || acceptanceMargin.isNegative() + || acceptanceMargin.compareTo(Duration.ofMinutes(5)) > 0 + || !expiresAt.minus(acceptanceMargin).isAfter(issuedAt.minus(allowedClockSkew))) { + throw new IllegalArgumentException("signed evidence acceptance window is invalid"); + } + this.environmentId = + NotificationIntentId.requireOpaque("evidence environment identity", environmentId); + this.databaseId = NotificationIntentId.requireOpaque("evidence database identity", databaseId); + this.artifactId = NotificationIntentId.requireOpaque("evidence artifact identity", artifactId); + this.consumerInventoryId = + NotificationIntentId.requireOpaque( + "evidence consumer inventory identity", consumerInventoryId); + this.providerCallLedgerId = + NotificationIntentId.requireOpaque( + "evidence provider-call ledger identity", providerCallLedgerId); + this.providerCallLedgerSnapshot = + NotificationIntentId.requireOpaque( + "evidence provider-call ledger snapshot", providerCallLedgerSnapshot); + if (childCount < 0 || childCount > 1_000) { + throw new IllegalArgumentException("signed evidence child count must be in 0..1000"); + } + this.childCount = childCount; + this.childSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(childSetDigest); + } + + public String canonicalProfile() { + return canonicalProfile; + } + + public byte[] canonicalPayload() { + return Arrays.copyOf(canonicalPayload, canonicalPayload.length); + } + + public byte[] signature() { + return Arrays.copyOf(signature, signature.length); + } + + public String signatureAlgorithm() { + return signatureAlgorithm; + } + + public String issuerKeyId() { + return issuerKeyId; + } + + public byte[] issuerPublicKeySpki() { + return Arrays.copyOf(issuerPublicKeySpki, issuerPublicKeySpki.length); + } + + public String issuerPublicKeyDigest() { + return issuerPublicKeyDigest; + } + + public NotificationEvidenceTrustSnapshot trustSnapshot() { + return trustSnapshot; + } + + public Instant issuedAt() { + return issuedAt; + } + + public Instant expiresAt() { + return expiresAt; + } + + public Duration allowedClockSkew() { + return allowedClockSkew; + } + + public Duration acceptanceMargin() { + return acceptanceMargin; + } + + public String environmentId() { + return environmentId; + } + + public String databaseId() { + return databaseId; + } + + public String artifactId() { + return artifactId; + } + + public String consumerInventoryId() { + return consumerInventoryId; + } + + public String providerCallLedgerId() { + return providerCallLedgerId; + } + + public String providerCallLedgerSnapshot() { + return providerCallLedgerSnapshot; + } + + public int childCount() { + return childCount; + } + + public String childSetDigest() { + return childSetDigest; + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof NotificationSignedEvidenceHeader other)) { + return false; + } + return childCount == other.childCount + && canonicalProfile.equals(other.canonicalProfile) + && Arrays.equals(canonicalPayload, other.canonicalPayload) + && Arrays.equals(signature, other.signature) + && signatureAlgorithm.equals(other.signatureAlgorithm) + && issuerKeyId.equals(other.issuerKeyId) + && Arrays.equals(issuerPublicKeySpki, other.issuerPublicKeySpki) + && issuerPublicKeyDigest.equals(other.issuerPublicKeyDigest) + && trustSnapshot.equals(other.trustSnapshot) + && issuedAt.equals(other.issuedAt) + && expiresAt.equals(other.expiresAt) + && allowedClockSkew.equals(other.allowedClockSkew) + && acceptanceMargin.equals(other.acceptanceMargin) + && environmentId.equals(other.environmentId) + && databaseId.equals(other.databaseId) + && artifactId.equals(other.artifactId) + && consumerInventoryId.equals(other.consumerInventoryId) + && providerCallLedgerId.equals(other.providerCallLedgerId) + && providerCallLedgerSnapshot.equals(other.providerCallLedgerSnapshot) + && childSetDigest.equals(other.childSetDigest); + } + + @Override + public int hashCode() { + int result = + Objects.hash( + canonicalProfile, + signatureAlgorithm, + issuerKeyId, + issuerPublicKeyDigest, + trustSnapshot, + issuedAt, + expiresAt, + allowedClockSkew, + acceptanceMargin, + environmentId, + databaseId, + artifactId, + consumerInventoryId, + providerCallLedgerId, + providerCallLedgerSnapshot, + childCount, + childSetDigest); + result = 31 * result + Arrays.hashCode(canonicalPayload); + result = 31 * result + Arrays.hashCode(signature); + result = 31 * result + Arrays.hashCode(issuerPublicKeySpki); + return result; + } + + @Override + public String toString() { + return "NotificationSignedEvidenceHeader[canonicalProfile=" + + canonicalProfile + + ", canonicalPayload=, signature=, signatureAlgorithm=" + + signatureAlgorithm + + ", issuerKeyId=" + + issuerKeyId + + ", issuerPublicKeySpki=, issuerPublicKeyDigest=" + + issuerPublicKeyDigest + + ", trustSnapshot=" + + trustSnapshot + + ", issuedAt=" + + issuedAt + + ", expiresAt=" + + expiresAt + + ", consumerInventoryId=" + + consumerInventoryId + + ", providerCallLedgerId=" + + providerCallLedgerId + + ", providerCallLedgerSnapshot=" + + providerCallLedgerSnapshot + + ", childCount=" + + childCount + + ", childSetDigest=" + + childSetDigest + + "]"; + } + + private static byte[] copyBounded( + String field, byte[] value, int minimumLength, int maximumLength) { + if (value == null || value.length < minimumLength || value.length > maximumLength) { + throw new IllegalArgumentException( + field + " length must be in " + minimumLength + ".." + maximumLength); + } + return Arrays.copyOf(value, value.length); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java new file mode 100644 index 0000000..8f32c71 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** Durable store capabilities and retained frozen revisions visible to application validation. */ +public record NotificationStoreCapabilityDescriptor( + boolean durableIntentStore, + boolean attemptJournal, + boolean receiptInbox, + int maximumBatch, + Set availablePolicyRevisions, + Set availableTemplateRevisions) { + + public NotificationStoreCapabilityDescriptor { + if (maximumBatch < 1 || maximumBatch > 100) { + throw new IllegalArgumentException("notification store maximum batch must be in 1..100"); + } + Objects.requireNonNull(availablePolicyRevisions, "available policy revisions must be non-null"); + TreeSet policies = new TreeSet<>(availablePolicyRevisions); + if (policies.isEmpty() + || policies.size() > 100 + || policies.stream().anyMatch(revision -> revision == null || revision < 1)) { + throw new IllegalArgumentException( + "available policy revisions must contain 1..100 positive revisions"); + } + availablePolicyRevisions = Collections.unmodifiableSet(policies); + Objects.requireNonNull( + availableTemplateRevisions, "available template revisions must be non-null"); + HashSet templates = new HashSet<>(availableTemplateRevisions); + if (templates.isEmpty() + || templates.size() > 100 + || templates.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("available template revisions must contain 1..100 values"); + } + availableTemplateRevisions = Collections.unmodifiableSet(templates); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java new file mode 100644 index 0000000..10d7b26 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** Persists technical suppression caused by hard bounce or complaint, not business consent. */ +@FunctionalInterface +public interface NotificationTechnicalSuppressionPort { + + void suppress(SuppressionMutation mutation); + + record SuppressionMutation( + NotificationRecipientReference recipient, + NotificationReasonCode reasonCode, + Instant suppressedAt) { + + public SuppressionMutation { + Objects.requireNonNull(recipient, "notification recipient reference must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(suppressedAt, "notification suppression time must be non-null"); + } + + @Override + public String toString() { + return "SuppressionMutation[recipient=, reasonCode=" + + reasonCode + + ", suppressedAt=" + + suppressedAt + + "]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java new file mode 100644 index 0000000..eeeee95 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Bounded, immutable and redacted template parameter bag over a closed scalar value set. */ +public record NotificationTemplateParameters(Map values) { + + private static final int MAXIMUM_PARAMETERS = 32; + + public NotificationTemplateParameters { + Objects.requireNonNull(values, "template parameters must be non-null"); + if (values.size() > MAXIMUM_PARAMETERS) { + throw new IllegalArgumentException( + "template parameters exceed " + MAXIMUM_PARAMETERS + " entries"); + } + LinkedHashMap copy = new LinkedHashMap<>(); + values.forEach( + (name, value) -> { + if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) { + throw new IllegalArgumentException( + "template parameter name must match [a-z][A-Za-z0-9]{0,63}"); + } + copy.put( + name, Objects.requireNonNull(value, "template parameter value must be non-null")); + }); + values = Collections.unmodifiableMap(copy); + } + + @Override + public String toString() { + return "NotificationTemplateParameters[names=" + values.keySet() + ", values=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java new file mode 100644 index 0000000..775a2c6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.notification; + +/** Immutable checked-in template identity and version. */ +public record NotificationTemplateRef(String templateId, int version) { + + public NotificationTemplateRef { + templateId = NotificationIntentId.requireSlug("templateId", templateId); + if (version < 1 || version > 1_000_000) { + throw new IllegalArgumentException("template version must be in 1..1000000"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java new file mode 100644 index 0000000..d9da83d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java @@ -0,0 +1,103 @@ +package dev.caskeleton.application.notification; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Currency; +import java.util.Objects; + +/** + * Closed scalar set accepted by the generic template boundary. Provider objects, raw HTML, JSON, + * collections and arbitrary objects cannot implement this sealed contract. + */ +public sealed interface NotificationTemplateValue + permits NotificationTemplateValue.SafeText, + NotificationTemplateValue.TrustedAbsoluteLinkReference, + NotificationTemplateValue.LocalDateValue, + NotificationTemplateValue.LocalDateTimeValue, + NotificationTemplateValue.IntegerValue, + NotificationTemplateValue.MoneyValue { + + record SafeText(String value) implements NotificationTemplateValue { + + public SafeText { + if (value == null || value.isBlank() || value.length() > 4_096) { + throw new IllegalArgumentException("safe text must contain 1..4096 characters"); + } + if (value.chars().anyMatch(character -> character == 0)) { + throw new IllegalArgumentException("safe text must not contain NUL"); + } + } + + @Override + public String toString() { + return "SafeText[value=]"; + } + } + + record TrustedAbsoluteLinkReference(String value) implements NotificationTemplateValue { + + public TrustedAbsoluteLinkReference { + value = NotificationIntentId.requireOpaque("trusted link reference", value); + } + + @Override + public String toString() { + return "TrustedAbsoluteLinkReference[value=]"; + } + } + + record LocalDateValue(LocalDate value) implements NotificationTemplateValue { + + public LocalDateValue { + Objects.requireNonNull(value, "local date value must be non-null"); + } + + @Override + public String toString() { + return "LocalDateValue[value=]"; + } + } + + record LocalDateTimeValue(LocalDateTime value, ZoneId zone) implements NotificationTemplateValue { + + public LocalDateTimeValue { + Objects.requireNonNull(value, "local date-time value must be non-null"); + Objects.requireNonNull(zone, "business time zone must be non-null"); + if (zone.getId().length() > 64) { + throw new IllegalArgumentException("business time zone exceeds 64 characters"); + } + } + + @Override + public String toString() { + return "LocalDateTimeValue[value=, zone=]"; + } + } + + record IntegerValue(long value) implements NotificationTemplateValue { + + @Override + public String toString() { + return "IntegerValue[value=]"; + } + } + + record MoneyValue(BigDecimal amount, Currency currency) implements NotificationTemplateValue { + + public MoneyValue { + Objects.requireNonNull(amount, "money amount must be non-null"); + Objects.requireNonNull(currency, "money currency must be non-null"); + if (amount.scale() < 0 || amount.scale() > 4 || amount.precision() > 19) { + throw new IllegalArgumentException( + "money amount must have precision at most 19 and scale in 0..4"); + } + } + + @Override + public String toString() { + return "MoneyValue[amount=, currency=]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java new file mode 100644 index 0000000..9c8ff08 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Durable legacy permit mutation boundary; ownership operations use dedicated operation ports. */ +public interface NotificationWriterCutoverPort { + + NotificationLegacyWriterPermitResult acquireLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt); + + NotificationLegacyWriterPermitResult releaseLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java new file mode 100644 index 0000000..f5551f0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * Verified complete BEGIN inventory evidence; never construct from caller-authored digests alone. + */ +public record NotificationWriterInventoryEvidence( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + Set nodeIds, + String nodeSetDigest, + Instant verifiedAt) { + + public NotificationWriterInventoryEvidence { + Objects.requireNonNull(route, "verified writer inventory route must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException( + "verified writer inventory generation must be non-negative"); + } + Objects.requireNonNull(nodeIds, "verified writer inventory nodes must be non-null"); + TreeSet nodes = new TreeSet<>(); + nodeIds.forEach( + node -> nodes.add(NotificationIntentId.requireOpaque("writer inventory node ID", node))); + if (nodes.size() > 100) { + throw new IllegalArgumentException("verified writer inventory exceeds 100 nodes"); + } + nodeIds = Collections.unmodifiableSet(nodes); + nodeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(nodeSetDigest); + Objects.requireNonNull(verifiedAt, "writer inventory verification time must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java new file mode 100644 index 0000000..f94dfb3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Verifies signed BEGIN inventory against the closed issuer trust catalog and exact context. */ +@FunctionalInterface +public interface NotificationWriterInventoryEvidenceVerifierPort { + + NotificationWriterInventoryEvidence verify( + SignedNotificationWriterInventoryManifest manifest, + NotificationCanonicalWriterRouteSet.RouteRevision expectedRoute, + long expectedGeneration, + Instant verifiedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java new file mode 100644 index 0000000..39d58ab --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Exclusive writer owner stored in the route-specific database fence. */ +public enum NotificationWriterOwnership { + LEGACY, + CANONICAL +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java new file mode 100644 index 0000000..2edc22a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** Verifies signed quiescence payload, issuer trust and exact route/registry context. */ +@FunctionalInterface +public interface NotificationWriterQuiescenceAttestationPort { + + VerifiedQuiescenceEvidence verify( + SignedNotificationWriterQuiescenceManifest manifest, + NotificationCanonicalWriterRouteSet.RouteRevision expectedRoute, + long expectedGeneration, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant verifiedAt); + + record VerifiedQuiescenceEvidence( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + String childSetDigest, + int childCount, + Instant verifiedAt) { + + public VerifiedQuiescenceEvidence { + Objects.requireNonNull(route, "verified quiescence route must be non-null"); + if (generation < 0 || childCount < 0 || childCount > 1_000) { + throw new IllegalArgumentException( + "verified quiescence generation/count is outside bounds"); + } + childSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(childSetDigest); + Objects.requireNonNull(verifiedAt, "quiescence verification time must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java new file mode 100644 index 0000000..e2d13bf --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java @@ -0,0 +1,157 @@ +package dev.caskeleton.application.notification; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * PRE-only route set decorating the canonical route keys with legacy aliases and the complete + * current-plus-retiring transport proof registry. + */ +public record NotificationWriterRouteSet( + NotificationCanonicalWriterRouteSet canonicalRoutes, List routeProfiles) { + + public NotificationWriterRouteSet { + Objects.requireNonNull(canonicalRoutes, "canonical writer route set must be non-null"); + Objects.requireNonNull(routeProfiles, "writer route profiles must be non-null"); + routeProfiles = + routeProfiles.stream() + .map( + profile -> Objects.requireNonNull(profile, "writer route profile must be non-null")) + .sorted( + Comparator.comparing((RouteProfile profile) -> profile.route().routeId().value()) + .thenComparingInt(profile -> profile.route().routeRevision())) + .toList(); + if (routeProfiles.size() != canonicalRoutes.routes().size()) { + throw new IllegalArgumentException( + "writer route profile keys must exactly equal canonical route keys"); + } + if (new HashSet<>(routeProfiles.stream().map(RouteProfile::route).toList()).size() + != routeProfiles.size()) { + throw new IllegalArgumentException("writer route profiles contain duplicate routes"); + } + if (!routeProfiles.stream() + .map(RouteProfile::route) + .toList() + .equals(canonicalRoutes.routes())) { + throw new IllegalArgumentException( + "writer route profile keys must exactly equal canonical route keys"); + } + } + + public RouteProfile requireRoute(NotificationCanonicalWriterRouteSet.RouteRevision route) { + return routeProfiles.stream() + .filter(candidate -> candidate.route().equals(route)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("route is outside trusted writer route set")); + } + + public String digest() { + MessageDigest digest = sha256(); + NotificationCanonicalWriterRouteSet.update(digest, canonicalRoutes.digest()); + routeProfiles.forEach( + route -> { + NotificationCanonicalWriterRouteSet.update(digest, route.route().routeId().value()); + digest.update( + ByteBuffer.allocate(Integer.BYTES).putInt(route.route().routeRevision()).array()); + NotificationCanonicalWriterRouteSet.update(digest, route.legacyAlias().orElse("")); + route + .transportProfiles() + .forEach( + profile -> { + NotificationCanonicalWriterRouteSet.update(digest, profile.profileId()); + NotificationCanonicalWriterRouteSet.update(digest, profile.proofClass().name()); + NotificationCanonicalWriterRouteSet.update(digest, profile.evidenceRevision()); + digest.update((byte) (profile.activeAdmissionProfile() ? 1 : 0)); + }); + }); + return java.util.HexFormat.of().formatHex(digest.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + public record RouteProfile( + NotificationCanonicalWriterRouteSet.RouteRevision route, + Optional legacyAlias, + List transportProfiles) { + + public RouteProfile { + Objects.requireNonNull(route, "notification writer route must be non-null"); + Objects.requireNonNull(legacyAlias, "legacy alias container must be non-null"); + legacyAlias = + legacyAlias.map(alias -> NotificationIntentId.requireSlug("legacy route alias", alias)); + Objects.requireNonNull( + transportProfiles, "legacy transport profile registry must be non-null"); + transportProfiles = + transportProfiles.stream() + .map( + profile -> + Objects.requireNonNull(profile, "legacy transport profile must be non-null")) + .sorted(Comparator.comparing(TransportProfile::profileId)) + .toList(); + if (transportProfiles.isEmpty() || transportProfiles.size() > 8) { + throw new IllegalArgumentException( + "legacy transport profile registry must contain 1..8 profiles"); + } + if (new HashSet<>(transportProfiles.stream().map(TransportProfile::profileId).toList()).size() + != transportProfiles.size()) { + throw new IllegalArgumentException("legacy transport profile registry contains duplicates"); + } + long activeCount = + transportProfiles.stream().filter(TransportProfile::activeAdmissionProfile).count(); + if (activeCount != 1) { + throw new IllegalArgumentException( + "legacy transport registry requires exactly one active admission profile"); + } + } + + public ProofClass proofRequirement() { + return transportProfiles.stream() + .allMatch(profile -> profile.proofClass() == ProofClass.HARD_BOUND_PROVEN) + ? ProofClass.HARD_BOUND_PROVEN + : ProofClass.QUIESCENCE_REQUIRED; + } + + public TransportProfile requireProfile(String profileId) { + return transportProfiles.stream() + .filter(profile -> profile.profileId().equals(profileId)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "transport profile is outside trusted writer registry")); + } + } + + public record TransportProfile( + String profileId, + ProofClass proofClass, + String evidenceRevision, + boolean activeAdmissionProfile) { + + public TransportProfile { + profileId = NotificationIntentId.requireSlug("legacy transport profile ID", profileId); + Objects.requireNonNull(proofClass, "legacy transport proof class must be non-null"); + evidenceRevision = + NotificationIntentId.requireSlug("legacy transport evidence revision", evidenceRevision); + } + } + + public enum ProofClass { + HARD_BOUND_PROVEN, + QUIESCENCE_REQUIRED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java new file mode 100644 index 0000000..7dac31f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java @@ -0,0 +1,92 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Provider-neutral attempt fact with submission, coordinator action and failure scope kept as + * separate axes. + */ +public record ProviderAttemptOutcome( + SubmissionCertainty submissionCertainty, + RetryDisposition retryDisposition, + NotificationFaultScope faultScope, + NotificationReasonCode reasonCode, + Optional retryNotBefore, + String attemptCorrelationReference, + Optional providerMessageReference) { + + public ProviderAttemptOutcome { + Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null"); + Objects.requireNonNull(retryDisposition, "retry disposition must be non-null"); + Objects.requireNonNull(faultScope, "fault scope must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null"); + attemptCorrelationReference = + NotificationIntentId.requireOpaque( + "attempt correlation reference", attemptCorrelationReference); + Objects.requireNonNull( + providerMessageReference, "provider message reference container must be non-null"); + providerMessageReference = + providerMessageReference.map( + value -> NotificationIntentId.requireOpaque("provider message reference", value)); + validateAxes( + submissionCertainty, + retryDisposition, + faultScope, + retryNotBefore, + providerMessageReference); + } + + private static void validateAxes( + SubmissionCertainty certainty, + RetryDisposition disposition, + NotificationFaultScope scope, + Optional retryAt, + Optional providerReference) { + if ((disposition == RetryDisposition.RETRY_AT) != retryAt.isPresent()) { + throw new IllegalArgumentException( + "retryNotBefore must be present exactly when retry disposition is RETRY_AT"); + } + if (certainty == SubmissionCertainty.PROVIDER_ACCEPTED + && (disposition != RetryDisposition.NOT_APPLICABLE + || scope != NotificationFaultScope.DELIVERY)) { + throw new IllegalArgumentException("PROVIDER_ACCEPTED requires NOT_APPLICABLE and DELIVERY"); + } + if (certainty == SubmissionCertainty.INDETERMINATE + && (disposition != RetryDisposition.NOT_APPLICABLE + || scope != NotificationFaultScope.DELIVERY + || providerReference.isPresent())) { + throw new IllegalArgumentException( + "INDETERMINATE requires NOT_APPLICABLE, DELIVERY and no provider message reference"); + } + if (certainty == SubmissionCertainty.DEFINITELY_NOT_APPLIED + && disposition == RetryDisposition.NOT_APPLICABLE) { + throw new IllegalArgumentException( + "DEFINITELY_NOT_APPLIED requires an explicit retry, park or terminal disposition"); + } + if (certainty != SubmissionCertainty.PROVIDER_ACCEPTED && providerReference.isPresent()) { + throw new IllegalArgumentException( + "provider message reference is only valid for a provider-accepted outcome"); + } + if (disposition == RetryDisposition.PARK_BINDING && scope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("PARK_BINDING requires a shared non-delivery fault scope"); + } + } + + @Override + public String toString() { + return "ProviderAttemptOutcome[submissionCertainty=" + + submissionCertainty + + ", retryDisposition=" + + retryDisposition + + ", faultScope=" + + faultScope + + ", reasonCode=" + + reasonCode + + ", retryNotBefore=" + + retryNotBefore + + ", attemptCorrelationReference=, providerMessageReference=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java new file mode 100644 index 0000000..d1a64b4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; + +/** Requests bounded provider reconciliation and/or local orphan-receipt attachment. */ +public record ReconcileNotificationDeliveriesCommand( + int maximumClaims, int maximumOrphanAttachments) implements Command { + + public ReconcileNotificationDeliveriesCommand { + if (maximumClaims < 0 + || maximumClaims > 100 + || maximumOrphanAttachments < 0 + || maximumOrphanAttachments > 100 + || maximumClaims + maximumOrphanAttachments == 0) { + throw new IllegalArgumentException( + "reconciliation bounds must each be in 0..100 and at least one must be positive"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java new file mode 100644 index 0000000..70601ec --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.notification; + +/** Bounded non-sensitive aggregate outcome of one reconciliation cycle. */ +public record ReconcileNotificationDeliveriesResult( + int claimedCount, int providerCallCount, int finalizedCount, int orphanAttachedCount) { + + public ReconcileNotificationDeliveriesResult { + int[] counts = {claimedCount, providerCallCount, finalizedCount, orphanAttachedCount}; + for (int count : counts) { + if (count < 0 || count > 100) { + throw new IllegalArgumentException("reconciliation counts must be in 0..100"); + } + } + if (providerCallCount > claimedCount || finalizedCount > providerCallCount) { + throw new IllegalArgumentException("reconciliation counts are inconsistent"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java new file mode 100644 index 0000000..554aa92 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java @@ -0,0 +1,84 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.List; +import java.util.Objects; + +/** + * Coordinates local orphan attach and provider reconciliation with short transaction boundaries. + */ +@RequiresPermission("notification:reconcile") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + sensitiveRead = true, + crossTenantAdmin = true) +public final class ReconcileNotificationDeliveriesUseCase + implements CommandUseCase< + ReconcileNotificationDeliveriesCommand, ReconcileNotificationDeliveriesResult> { + + private final NotificationDeliveryStorePort store; + private final NotificationReconciliationPort provider; + private final TransactionPort transactions; + private final Clock clock; + + public ReconcileNotificationDeliveriesUseCase( + NotificationDeliveryStorePort store, + NotificationReconciliationPort provider, + TransactionPort transactions, + Clock clock) { + this.store = Objects.requireNonNull(store, "notification delivery store must be non-null"); + this.provider = + Objects.requireNonNull(provider, "notification reconciliation port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public ReconcileNotificationDeliveriesResult handle( + ReconcileNotificationDeliveriesCommand command) { + Objects.requireNonNull(command, "reconciliation command must be non-null"); + int orphanAttached = + command.maximumOrphanAttachments() == 0 + ? 0 + : transactions.inWrite( + () -> + store.attachOrphanReceipts( + command.maximumOrphanAttachments(), clock.instant())); + List claims = + command.maximumClaims() == 0 + ? List.of() + : List.copyOf( + transactions.inWrite( + () -> store.claimForReconciliation(command.maximumClaims(), clock.instant()))); + if (claims.size() > command.maximumClaims()) { + throw new IllegalStateException("notification store returned too many reconciliation claims"); + } + + int finalized = 0; + for (NotificationDeliveryStorePort.ReconciliationClaim claim : claims) { + NotificationReconciliationPort.ReconciliationOutcome outcome = + Objects.requireNonNull( + provider.reconcile(claim), "reconciliation outcome must be non-null"); + NotificationDeliveryStorePort.ReconciliationFinalizationResult finalization = + transactions.inWrite(() -> store.finalizeReconciliation(claim, outcome, clock.instant())); + if (finalization == NotificationDeliveryStorePort.ReconciliationFinalizationResult.APPLIED + || finalization + == NotificationDeliveryStorePort.ReconciliationFinalizationResult + .LATE_EXACT_APPLIED) { + finalized++; + } + } + return new ReconcileNotificationDeliveriesResult( + claims.size(), claims.size(), finalized, orphanAttached); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java new file mode 100644 index 0000000..6163ac4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Authenticated PRE request to verify and retain one immutable signed quiescence manifest. */ +public record RecordNotificationWriterQuiescenceAttestationCommand( + String operationToken, + SignedNotificationWriterQuiescenceManifest manifest, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public RecordNotificationWriterQuiescenceAttestationCommand { + operationToken = + NotificationIntentId.requireOpaque( + "quiescence attestation operation token", operationToken); + Objects.requireNonNull(manifest, "signed quiescence manifest must be non-null"); + actorReference = + NotificationIntentId.requireOpaque("quiescence attestation actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java new file mode 100644 index 0000000..1655b68 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Atomically re-derives current blocking sets and retains verified signed quiescence evidence. */ +@FunctionalInterface +public interface RecordNotificationWriterQuiescenceAttestationOperation { + + RecordNotificationWriterQuiescenceAttestationResult record( + RecordNotificationWriterQuiescenceAttestationCommand command, + NotificationWriterQuiescenceAttestationPort.VerifiedQuiescenceEvidence verifiedEvidence, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java new file mode 100644 index 0000000..d1284fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed retained quiescence evidence identity. */ +public record RecordNotificationWriterQuiescenceAttestationResult( + Status status, + String operationToken, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + String childSetDigest) { + + public RecordNotificationWriterQuiescenceAttestationResult { + Objects.requireNonNull(status, "quiescence attestation status must be non-null"); + operationToken = + NotificationIntentId.requireOpaque( + "quiescence attestation operation token", operationToken); + Objects.requireNonNull(route, "quiescence attestation route must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("quiescence generation must be non-negative"); + } + childSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(childSetDigest); + } + + public enum Status { + RECORDED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java new file mode 100644 index 0000000..4beed31 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java @@ -0,0 +1,80 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; +import java.util.Set; + +/** + * Verifies signed quiescence outside a transaction, then root-commits its exact retained evidence. + */ +@RequiresPermission("notification:cutover-attest") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class RecordNotificationWriterQuiescenceAttestationUseCase + implements CommandUseCase< + RecordNotificationWriterQuiescenceAttestationCommand, + RecordNotificationWriterQuiescenceAttestationResult> { + + private final NotificationWriterRouteSet routes; + private final NotificationWriterQuiescenceAttestationPort verifier; + private final RecordNotificationWriterQuiescenceAttestationOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public RecordNotificationWriterQuiescenceAttestationUseCase( + NotificationWriterRouteSet routes, + NotificationWriterQuiescenceAttestationPort verifier, + RecordNotificationWriterQuiescenceAttestationOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.verifier = + Objects.requireNonNull(verifier, "quiescence evidence verifier must be non-null"); + this.operation = + Objects.requireNonNull(operation, "quiescence attestation operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public RecordNotificationWriterQuiescenceAttestationResult handle( + RecordNotificationWriterQuiescenceAttestationCommand command) { + Objects.requireNonNull(command, "quiescence attestation command must be non-null"); + SignedNotificationWriterQuiescenceManifest manifest = command.manifest(); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(manifest.route()); + if (route.proofRequirement() != NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED) { + throw new IllegalArgumentException( + "all-hard-bound writer route forbids quiescence attestation evidence"); + } + Set trustedProfileIds = + route.transportProfiles().stream() + .map(NotificationWriterRouteSet.TransportProfile::profileId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (!manifest.transportProfileIds().equals(trustedProfileIds)) { + throw new IllegalArgumentException( + "signed quiescence transport profiles must exactly match trusted writer registry"); + } + NotificationWriterQuiescenceAttestationPort.VerifiedQuiescenceEvidence evidence = + Objects.requireNonNull( + verifier.verify( + manifest, manifest.route(), manifest.drainingGeneration(), route, clock.instant()), + "verified quiescence evidence must be non-null"); + if (!evidence.route().equals(manifest.route()) + || evidence.generation() != manifest.drainingGeneration()) { + throw new IllegalArgumentException( + "verified quiescence evidence does not match command route/generation"); + } + return transactions.inRootWrite( + () -> operation.record(command, evidence, route, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java new file mode 100644 index 0000000..baf8106 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Coordinator action after one provider attempt. */ +public enum RetryDisposition { + RETRY_AT, + PARK_BINDING, + TERMINAL, + NOT_APPLICABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java new file mode 100644 index 0000000..5d6c4b2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Signed complete old-writer node inventory presented to BEGIN_DRAIN. */ +public record SignedNotificationWriterInventoryManifest( + NotificationSignedEvidenceHeader header, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + List nodes) { + + public SignedNotificationWriterInventoryManifest { + Objects.requireNonNull(header, "signed inventory header must be non-null"); + Objects.requireNonNull(route, "signed inventory route must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("signed inventory generation must be non-negative"); + } + nodes = List.copyOf(Objects.requireNonNull(nodes, "signed inventory nodes must be non-null")); + if (nodes.size() > 100 || nodes.size() > header.childCount()) { + throw new IllegalArgumentException( + "signed inventory node count exceeds bounded signed child count"); + } + if (nodes.stream() + .map(NodeInventory::nodeId) + .collect(java.util.stream.Collectors.toSet()) + .size() + != nodes.size()) { + throw new IllegalArgumentException("signed inventory contains duplicate node IDs"); + } + } + + public Set nodeIds() { + return java.util.Collections.unmodifiableSet( + new java.util.TreeSet<>(nodes.stream().map(NodeInventory::nodeId).toList())); + } + + public record NodeInventory(String nodeId, String artifactId) { + + public NodeInventory { + nodeId = NotificationIntentId.requireOpaque("writer inventory node ID", nodeId); + artifactId = NotificationIntentId.requireOpaque("writer inventory artifact ID", artifactId); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java new file mode 100644 index 0000000..e9764b8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** Signed exact irreversible quiescence evidence for a QUIESCENCE_REQUIRED route generation. */ +public record SignedNotificationWriterQuiescenceManifest( + NotificationSignedEvidenceHeader header, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long drainingGeneration, + Set transportProfileIds, + List nodes, + int blockingPermitCount, + String blockingPermitSetDigest, + Set permitHolderIds, + int productionConsumerCount, + int providerCallOpenCount) { + + public SignedNotificationWriterQuiescenceManifest { + Objects.requireNonNull(header, "signed quiescence header must be non-null"); + Objects.requireNonNull(route, "signed quiescence route must be non-null"); + if (drainingGeneration < 0) { + throw new IllegalArgumentException("draining generation must be non-negative"); + } + Objects.requireNonNull( + transportProfileIds, "signed quiescence transport profiles must be non-null"); + TreeSet profiles = new TreeSet<>(); + transportProfileIds.forEach( + profile -> + profiles.add(NotificationIntentId.requireSlug("legacy transport profile ID", profile))); + if (profiles.isEmpty() || profiles.size() > 8) { + throw new IllegalArgumentException( + "signed quiescence transport profile set must contain 1..8 profiles"); + } + transportProfileIds = java.util.Collections.unmodifiableSet(profiles); + nodes = List.copyOf(Objects.requireNonNull(nodes, "signed quiescence nodes must be non-null")); + if (nodes.size() > 100 || nodes.size() > header.childCount()) { + throw new IllegalArgumentException( + "signed quiescence node count exceeds bounded signed child count"); + } + if (blockingPermitCount < 0 || blockingPermitCount > 100) { + throw new IllegalArgumentException("blocking permit count must be in 0..100"); + } + blockingPermitSetDigest = + InitializeNotificationWriterFencesCommand.requireDigest(blockingPermitSetDigest); + Objects.requireNonNull(permitHolderIds, "permit holder set must be non-null"); + TreeSet holders = new TreeSet<>(); + permitHolderIds.forEach( + holder -> holders.add(NotificationIntentId.requireOpaque("legacy permit holder", holder))); + if (holders.size() > 100) { + throw new IllegalArgumentException("permit holder set exceeds 100 entries"); + } + permitHolderIds = java.util.Collections.unmodifiableSet(holders); + if (productionConsumerCount != 0 || providerCallOpenCount != 0) { + throw new IllegalArgumentException( + "quiescence evidence requires production consumer and provider open counts of zero"); + } + } + + public record NodeQuiescence( + String nodeId, + boolean retired, + boolean quiesced, + boolean deploymentTombstoned, + boolean credentialRevoked, + boolean egressRevoked) { + + public NodeQuiescence { + nodeId = NotificationIntentId.requireOpaque("quiescence node ID", nodeId); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java new file mode 100644 index 0000000..05fd186 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.notification; + +/** Opaque Slack workspace binding and audience references; neither value is a webhook URL. */ +public record SlackAudienceReference(String workspaceBindingReference, String audienceReference) + implements NotificationRecipientReference { + + public SlackAudienceReference { + workspaceBindingReference = + NotificationIntentId.requireOpaque( + "Slack workspace binding reference", workspaceBindingReference); + audienceReference = + NotificationIntentId.requireOpaque("Slack audience reference", audienceReference); + } + + @Override + public NotificationChannel channel() { + return NotificationChannel.SLACK; + } + + @Override + public String toString() { + return "SlackAudienceReference[workspaceBindingReference=, " + + "audienceReference=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java new file mode 100644 index 0000000..f00c428 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Whether one provider call could have produced an external side effect. */ +public enum SubmissionCertainty { + DEFINITELY_NOT_APPLIED, + PROVIDER_ACCEPTED, + INDETERMINATE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java new file mode 100644 index 0000000..bd85e29 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java @@ -0,0 +1,130 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; +import java.util.Optional; + +/** + * Audited closed writer transition request. No target owner field exists; action determines the + * only legal result owner. + */ +public record SwitchNotificationWriterOwnershipCommand( + Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + long reviewedTargetGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Optional inventoryManifest, + Optional quiescenceAttestationToken) + implements Command { + + public SwitchNotificationWriterOwnershipCommand { + Objects.requireNonNull(action, "writer ownership action must be non-null"); + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (expectedGeneration < 0 || reviewedTargetGeneration < 0) { + throw new IllegalArgumentException("writer generations must be non-negative"); + } + operationToken = + NotificationIntentId.requireOpaque("writer switch operation token", operationToken); + actorReference = NotificationIntentId.requireOpaque("writer switch actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(inventoryManifest, "inventory manifest container must be non-null"); + Objects.requireNonNull( + quiescenceAttestationToken, "quiescence attestation token container must be non-null"); + quiescenceAttestationToken = + quiescenceAttestationToken.map( + token -> NotificationIntentId.requireOpaque("quiescence attestation token", token)); + switch (action) { + case BEGIN_DRAIN -> { + if (reviewedTargetGeneration != expectedGeneration + || inventoryManifest.isEmpty() + || quiescenceAttestationToken.isPresent()) { + throw new IllegalArgumentException( + "BEGIN_DRAIN keeps generation, requires inventory and forbids attestation token"); + } + } + case COMPLETE_SWITCH -> { + if (reviewedTargetGeneration != Math.addExact(expectedGeneration, 1) + || inventoryManifest.isPresent()) { + throw new IllegalArgumentException( + "COMPLETE_SWITCH requires exactly generation+1 and no caller inventory"); + } + } + case ABORT_DRAIN -> { + if (reviewedTargetGeneration != Math.addExact(expectedGeneration, 1) + || inventoryManifest.isPresent() + || quiescenceAttestationToken.isPresent()) { + throw new IllegalArgumentException( + "ABORT_DRAIN requires exactly generation+1 and no evidence inputs"); + } + } + default -> throw new IllegalArgumentException("unsupported writer ownership action"); + } + } + + public static SwitchNotificationWriterOwnershipCommand beginDrain( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + SignedNotificationWriterInventoryManifest inventoryManifest) { + return new SwitchNotificationWriterOwnershipCommand( + Action.BEGIN_DRAIN, + route, + expectedGeneration, + expectedGeneration, + operationToken, + actorReference, + reasonCode, + Optional.of(inventoryManifest), + Optional.empty()); + } + + public static SwitchNotificationWriterOwnershipCommand completeSwitch( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + long reviewedTargetGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Optional quiescenceAttestationToken) { + return new SwitchNotificationWriterOwnershipCommand( + Action.COMPLETE_SWITCH, + route, + expectedGeneration, + reviewedTargetGeneration, + operationToken, + actorReference, + reasonCode, + Optional.empty(), + quiescenceAttestationToken); + } + + public static SwitchNotificationWriterOwnershipCommand abortDrain( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + long reviewedTargetGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode) { + return new SwitchNotificationWriterOwnershipCommand( + Action.ABORT_DRAIN, + route, + expectedGeneration, + reviewedTargetGeneration, + operationToken, + actorReference, + reasonCode, + Optional.empty(), + Optional.empty()); + } + + public enum Action { + BEGIN_DRAIN, + COMPLETE_SWITCH, + ABORT_DRAIN + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java new file mode 100644 index 0000000..fede581 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Optional; + +/** + * Atomic persistence operation that locks/re-verifies retained evidence and applies only the closed + * LEGACY transition matrix. + */ +@FunctionalInterface +public interface SwitchNotificationWriterOwnershipOperation { + + SwitchNotificationWriterOwnershipResult switchOwnership( + SwitchNotificationWriterOwnershipCommand command, + Optional verifiedInventory, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java new file mode 100644 index 0000000..18ac693 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java @@ -0,0 +1,70 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed result of one closed writer-fence transition. */ +public record SwitchNotificationWriterOwnershipResult( + Status status, + SwitchNotificationWriterOwnershipCommand.Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + FenceState state, + NotificationWriterOwnership owner, + long generation, + String operationToken) { + + public SwitchNotificationWriterOwnershipResult { + Objects.requireNonNull(status, "writer switch status must be non-null"); + Objects.requireNonNull(action, "writer switch action must be non-null"); + Objects.requireNonNull(route, "notification writer route must be non-null"); + Objects.requireNonNull(state, "writer fence state must be non-null"); + Objects.requireNonNull(owner, "notification writer owner must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("writer generation must be non-negative"); + } + operationToken = + NotificationIntentId.requireOpaque("writer switch operation token", operationToken); + validateMatrix(action, state, owner); + } + + public static SwitchNotificationWriterOwnershipResult applied( + SwitchNotificationWriterOwnershipCommand.Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + NotificationWriterOwnership owner, + long generation, + String operationToken) { + FenceState state = + action == SwitchNotificationWriterOwnershipCommand.Action.BEGIN_DRAIN + ? FenceState.DRAINING + : FenceState.ACTIVE; + return new SwitchNotificationWriterOwnershipResult( + Status.APPLIED, action, route, state, owner, generation, operationToken); + } + + private static void validateMatrix( + SwitchNotificationWriterOwnershipCommand.Action action, + FenceState state, + NotificationWriterOwnership owner) { + boolean valid = + switch (action) { + case BEGIN_DRAIN -> + state == FenceState.DRAINING && owner == NotificationWriterOwnership.LEGACY; + case COMPLETE_SWITCH -> + state == FenceState.ACTIVE && owner == NotificationWriterOwnership.CANONICAL; + case ABORT_DRAIN -> + state == FenceState.ACTIVE && owner == NotificationWriterOwnership.LEGACY; + }; + if (!valid) { + throw new IllegalArgumentException("writer switch result violates closed transition matrix"); + } + } + + public enum Status { + APPLIED, + REPLAYED + } + + public enum FenceState { + ACTIVE, + DRAINING + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java new file mode 100644 index 0000000..2e2aacc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java @@ -0,0 +1,100 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; + +/** + * Validates signed evidence requirements and root-commits one closed writer ownership transition. + */ +@RequiresPermission("notification:cutover") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class SwitchNotificationWriterOwnershipUseCase + implements CommandUseCase< + SwitchNotificationWriterOwnershipCommand, SwitchNotificationWriterOwnershipResult> { + + private final NotificationWriterRouteSet routes; + private final NotificationWriterInventoryEvidenceVerifierPort inventoryVerifier; + private final SwitchNotificationWriterOwnershipOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public SwitchNotificationWriterOwnershipUseCase( + NotificationWriterRouteSet routes, + NotificationWriterInventoryEvidenceVerifierPort inventoryVerifier, + SwitchNotificationWriterOwnershipOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.inventoryVerifier = + Objects.requireNonNull(inventoryVerifier, "writer inventory verifier must be non-null"); + this.operation = + Objects.requireNonNull(operation, "writer ownership switch operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public SwitchNotificationWriterOwnershipResult handle( + SwitchNotificationWriterOwnershipCommand command) { + Objects.requireNonNull(command, "writer ownership switch command must be non-null"); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(command.route()); + validateEvidenceRequirement(command, route); + Optional inventory = verifyBeginInventory(command); + return transactions.inRootWrite( + () -> operation.switchOwnership(command, inventory, route, clock.instant())); + } + + private Optional verifyBeginInventory( + SwitchNotificationWriterOwnershipCommand command) { + if (command.action() != SwitchNotificationWriterOwnershipCommand.Action.BEGIN_DRAIN) { + return Optional.empty(); + } + SignedNotificationWriterInventoryManifest manifest = command.inventoryManifest().orElseThrow(); + if (!manifest.route().equals(command.route()) + || manifest.generation() != command.expectedGeneration()) { + throw new IllegalArgumentException( + "signed writer inventory must match BEGIN route and generation"); + } + NotificationWriterInventoryEvidence evidence = + Objects.requireNonNull( + inventoryVerifier.verify( + manifest, command.route(), command.expectedGeneration(), clock.instant()), + "verified writer inventory evidence must be non-null"); + if (!evidence.route().equals(command.route()) + || evidence.generation() != command.expectedGeneration()) { + throw new IllegalArgumentException( + "verified writer inventory does not match BEGIN route and generation"); + } + return Optional.of(evidence); + } + + private static void validateEvidenceRequirement( + SwitchNotificationWriterOwnershipCommand command, + NotificationWriterRouteSet.RouteProfile route) { + if (command.action() != SwitchNotificationWriterOwnershipCommand.Action.COMPLETE_SWITCH) { + return; + } + if (route.proofRequirement() == NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED + && command.quiescenceAttestationToken().isEmpty()) { + throw new IllegalArgumentException( + "QUIESCENCE_REQUIRED route requires a committed attestation token"); + } + if (route.proofRequirement() == NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN + && command.quiescenceAttestationToken().isPresent()) { + throw new IllegalArgumentException( + "all-hard-bound route forbids quiescence attestation evidence"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java new file mode 100644 index 0000000..a7910d7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Bounded target-ordinal outcome for one provider leg. */ +public record TargetAttemptOutcome( + int targetOrdinal, NotificationDeliveryId deliveryId, ProviderAttemptOutcome providerOutcome) { + + public TargetAttemptOutcome { + if (targetOrdinal < 0 || targetOrdinal >= 16) { + throw new IllegalArgumentException("target ordinal must be in 0..15"); + } + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null"); + } + + @Override + public String toString() { + return "TargetAttemptOutcome[targetOrdinal=" + + targetOrdinal + + ", deliveryId=, providerOutcome=" + + providerOutcome + + "]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java new file mode 100644 index 0000000..03479d0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** PRE-only bounded DB-time terminalization request for expired ACTIVE legacy permits. */ +public record TerminalizeExpiredNotificationWriterPermitsCommand( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long drainingGeneration, + int maximumPermits, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public TerminalizeExpiredNotificationWriterPermitsCommand { + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (drainingGeneration < 0) { + throw new IllegalArgumentException("draining generation must be non-negative"); + } + if (maximumPermits < 1 || maximumPermits > 100) { + throw new IllegalArgumentException("terminalization permit bound must be in 1..100"); + } + operationToken = + NotificationIntentId.requireOpaque( + "permit terminalization operation token", operationToken); + actorReference = + NotificationIntentId.requireOpaque("permit terminalization actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java new file mode 100644 index 0000000..6bae7b2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Atomic operation selecting and CAS-terminalizing DB-time-expired permits in canonical order. */ +@FunctionalInterface +public interface TerminalizeExpiredNotificationWriterPermitsOperation { + + TerminalizeExpiredNotificationWriterPermitsResult terminalize( + TerminalizeExpiredNotificationWriterPermitsCommand command, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java new file mode 100644 index 0000000..394f9a1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed terminalized permit count and canonical affected tuple-set digest. */ +public record TerminalizeExpiredNotificationWriterPermitsResult( + Status status, int affectedCount, String affectedSetDigest) { + + public TerminalizeExpiredNotificationWriterPermitsResult { + Objects.requireNonNull(status, "permit terminalization status must be non-null"); + if (affectedCount < 0 || affectedCount > 100) { + throw new IllegalArgumentException("terminalized permit count must be in 0..100"); + } + affectedSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(affectedSetDigest); + } + + public enum Status { + APPLIED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java new file mode 100644 index 0000000..03ae782 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java @@ -0,0 +1,49 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Root-commits one bounded expired-permit terminalization operation. */ +@RequiresPermission("notification:cutover-terminalize") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class TerminalizeExpiredNotificationWriterPermitsUseCase + implements CommandUseCase< + TerminalizeExpiredNotificationWriterPermitsCommand, + TerminalizeExpiredNotificationWriterPermitsResult> { + + private final NotificationWriterRouteSet routes; + private final TerminalizeExpiredNotificationWriterPermitsOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public TerminalizeExpiredNotificationWriterPermitsUseCase( + NotificationWriterRouteSet routes, + TerminalizeExpiredNotificationWriterPermitsOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.operation = + Objects.requireNonNull(operation, "permit terminalization operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public TerminalizeExpiredNotificationWriterPermitsResult handle( + TerminalizeExpiredNotificationWriterPermitsCommand command) { + Objects.requireNonNull(command, "permit terminalization command must be non-null"); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(command.route()); + return transactions.inRootWrite(() -> operation.terminalize(command, route, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java new file mode 100644 index 0000000..d431613 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.transaction; + +/** + * Raised when a root-only transaction operation is invoked while an actual transaction is already + * active on the calling thread. + */ +public final class NestedRootTransactionRejectedException extends RuntimeException { + + public NestedRootTransactionRejectedException() { + super("root write transaction requires no ambient actual transaction"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java index 35c79eb..f710eef 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java @@ -10,6 +10,8 @@ import java.util.function.Supplier; *

    *
  • {@link #inWrite(Supplier)} — REQUIRED + read-write, {@code READ_COMMITTED}. Command * default. + *
  • {@link #inRootWrite(Supplier)} — root-only REQUIRED + read-write, {@code READ_COMMITTED}; + * rejects an ambient actual transaction before invoking the action. *
  • {@link #inRead(Supplier)} — REQUIRED + read-only, {@code READ_COMMITTED}. Query default. *
  • {@link #inNew(Supplier)} — REQUIRES_NEW; outbox / audit / compensation only. *
@@ -22,6 +24,17 @@ public interface TransactionPort { T inWrite(Supplier action); + /** + * Run {@code action} in a root write transaction and return only after its physical commit. + * + *

An implementation must reject an already-active actual transaction before invoking the + * action or transaction manager. This is intentionally abstract: delegating to join-capable + * {@link #inWrite(Supplier)} would silently weaken the contract. + * + * @throws NestedRootTransactionRejectedException when an actual transaction is already active + */ + T inRootWrite(Supplier action); + T inRead(Supplier action); /** @@ -39,6 +52,14 @@ public interface TransactionPort { }); } + default void inRootWrite(Runnable action) { + inRootWrite( + () -> { + action.run(); + return null; + }); + } + default void inRead(Runnable action) { inRead( () -> { diff --git a/src/application-core/src/redisPolicyContractTest/java/dev/caskeleton/application/redis/RedisPolicyBoundaryContractTest.java b/src/application-core/src/redisPolicyContractTest/java/dev/caskeleton/application/redis/RedisPolicyBoundaryContractTest.java new file mode 100644 index 0000000..9c104c7 --- /dev/null +++ b/src/application-core/src/redisPolicyContractTest/java/dev/caskeleton/application/redis/RedisPolicyBoundaryContractTest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.application.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.cache.CacheRefreshCoordinationPort; +import dev.caskeleton.application.cache.CacheRegionPort; +import dev.caskeleton.application.idempotency.IdempotencyClaimAttempt; +import dev.caskeleton.application.idempotency.IdempotencyClaimRequest; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStorePortV2; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.lease.DistributedLeasePort; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.Test; + +class RedisPolicyBoundaryContractTest { + + @Test + void providerNeutralPortsExposeNoRedisSpringOrTransportTypes() { + List> ports = + List.of( + CacheRegionPort.class, + CacheRefreshCoordinationPort.class, + IdempotencyStorePortV2.class, + DistributedLeasePort.class); + + assertThat(ports) + .allSatisfy( + port -> + assertThat(List.of(port.getMethods())) + .allSatisfy(RedisPolicyBoundaryContractTest::assertProviderNeutral)); + } + + @Test + void idempotencyRecoveryRetentionMustOutliveTheProcessingLease() { + IdempotencyScope scope = + IdempotencyScope.of("principal-digest", "request-key", "create-worklog"); + IdempotencyClaimAttempt attempt = + new IdempotencyClaimAttempt("ownerToken_1234567890", "claimOperation_1234567890"); + + assertThatThrownBy( + () -> + new IdempotencyClaimRequest( + scope, + RequestFingerprint.ofSha256(new byte[] {1, 2, 3}), + attempt, + Duration.ofSeconds(30), + Duration.ofSeconds(30), + "json-v1", + "policy-v1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("outlive"); + } + + private static void assertProviderNeutral(Method method) { + assertNeutral(method.getReturnType(), method); + for (Class parameter : method.getParameterTypes()) { + assertNeutral(parameter, method); + } + } + + private static void assertNeutral(Class type, Method method) { + String name = type.getName().toLowerCase(Locale.ROOT); + assertThat(name) + .as("%s must remain provider/framework neutral", method) + .doesNotContain(".adapter.outbound.cache.redis.") + .doesNotContain("io.lettuce") + .doesNotContain("springframework") + .doesNotContain("jakarta.servlet") + .doesNotContain("java.sql"); + assertThat(type.getSimpleName().toLowerCase(Locale.ROOT)) + .as("%s must not expose a provider-named type", method) + .doesNotStartWith("redis"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java new file mode 100644 index 0000000..8f1c244 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java @@ -0,0 +1,888 @@ +package dev.caskeleton.application.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class CacheAsideExecutorTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + private static final Instant SOFT_EXPIRES_AT = NOW.plusSeconds(30); + private static final Instant HARD_EXPIRES_AT = NOW.plusSeconds(60); + private static final CacheObservationToken OBSERVATION = + new CacheObservationToken("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + @Test + void freshAndNegativeHitsNeverInvokeTheSource() { + AtomicInteger loads = new AtomicInteger(); + CacheAsideExecutor executor = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)); + FakeRegion fresh = + new FakeRegion( + new CacheLookup.Hit<>( + "cached", CacheLookup.Freshness.FRESH, "rev-1", SOFT_EXPIRES_AT, HARD_EXPIRES_AT)); + FakeRegion negative = + new FakeRegion( + new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND, HARD_EXPIRES_AT)); + + CacheResult freshResult = + executor.getOrLoad( + "key", fresh, loader(loads, new SourceLoadOutcome.Loaded<>("source", "rev-2"))); + CacheResult negativeResult = + executor.getOrLoad( + "key", negative, loader(loads, new SourceLoadOutcome.Loaded<>("source", "rev-2"))); + + assertThat(freshResult).isEqualTo(new CacheResult.FreshHit<>("cached", "rev-1")); + assertThat(negativeResult) + .isEqualTo(new CacheResult.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND)); + assertThat(loads).hasValue(0); + } + + @Test + void recordsLoadedValuesAndOnlyClassifiedAuthoritativeAbsenceAsNegative() { + CacheAsideExecutor executor = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)); + FakeRegion loadedRegion = new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + FakeRegion absentRegion = new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + FakeRegion failedRegion = new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + SourceFailure transientFailure = + new SourceFailure("SOURCE_TIMEOUT", new IllegalStateException()); + + CacheResult loaded = + executor.getOrLoad( + "loaded", + loadedRegion, + (key, cancellation) -> new SourceLoadOutcome.Loaded<>("value", "rev-2")); + CacheResult absent = + executor.getOrLoad( + "absent", + absentRegion, + (key, cancellation) -> + new SourceLoadOutcome.AuthoritativeAbsent<>( + AuthoritativeAbsence.NOT_FOUND, "rev-3")); + CacheResult failed = + executor.getOrLoad( + "failed", + failedRegion, + (key, cancellation) -> new SourceLoadOutcome.TransientFailure<>(transientFailure)); + + assertThat(loaded) + .isEqualTo( + new CacheResult.LoadedFromSource<>("value", "rev-2", CacheRecordOutcome.RECORDED)); + assertThat(loadedRegion.positiveRecords).containsExactly("loaded"); + assertThat(loadedRegion.recordIntents).containsExactly(CacheRecordIntent.ONLY_IF_ABSENT); + assertThat(absent) + .isEqualTo( + new CacheResult.AuthoritativeAbsent<>( + AuthoritativeAbsence.NOT_FOUND, "rev-3", CacheRecordOutcome.RECORDED)); + assertThat(absentRegion.negativeRecords).containsExactly("absent"); + assertThat(absentRegion.recordIntents).containsExactly(CacheRecordIntent.ONLY_IF_ABSENT); + assertThat(failed) + .isEqualTo( + new CacheResult.SourceFailed<>( + transientFailure, CacheResult.SourceFailureKind.TRANSIENT)); + assertThat(failedRegion.negativeRecords).isEmpty(); + } + + @Test + void servesStaleOnlyAfterAClassifiedTransientFailure() { + SourceFailure transientFailure = + new SourceFailure("SOURCE_TIMEOUT", new IllegalStateException()); + SourceFailure permanentFailure = + new SourceFailure("SOURCE_REJECTED", new IllegalArgumentException()); + FakeRegion region = + new FakeRegion( + new CacheLookup.Hit<>( + "stale", + CacheLookup.Freshness.STALE, + "rev-1", + NOW.minusSeconds(1), + HARD_EXPIRES_AT, + OBSERVATION)); + + CacheResult transientResult = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)) + .getOrLoad( + "key", + region, + (key, cancellation) -> new SourceLoadOutcome.TransientFailure<>(transientFailure)); + CacheResult permanentResult = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)) + .getOrLoad( + "key", + region, + (key, cancellation) -> new SourceLoadOutcome.PermanentFailure<>(permanentFailure)); + + assertThat(transientResult) + .isEqualTo( + new CacheResult.StaleFallbackAfterTransientFailure<>( + "stale", "rev-1", transientFailure)); + assertThat(permanentResult) + .isEqualTo( + new CacheResult.SourceFailed<>( + permanentFailure, CacheResult.SourceFailureKind.PERMANENT)); + } + + @Test + void neverServesAStaleCandidateAfterItsHardExpiry() { + MutableClock clock = new MutableClock(NOW); + SourceFailure transientFailure = + new SourceFailure("SOURCE_TIMEOUT", new IllegalStateException()); + FakeRegion region = + new FakeRegion( + new CacheLookup.Hit<>( + "stale", + CacheLookup.Freshness.STALE, + "rev-1", + NOW.minusSeconds(1), + NOW.plusSeconds(1), + OBSERVATION)); + + CacheResult result = + executor(policy(), clock) + .getOrLoad( + "key", + region, + (key, cancellation) -> { + clock.advance(Duration.ofSeconds(2)); + return new SourceLoadOutcome.TransientFailure<>(transientFailure); + }); + + assertThat(result) + .isEqualTo( + new CacheResult.SourceFailed<>( + transientFailure, CacheResult.SourceFailureKind.TRANSIENT)); + } + + @Test + void conditionallyReplacesApprovedSchemaAndRefusesAnUnobservableEntry() { + AtomicInteger loads = new AtomicInteger(); + FakeRegion reloadable = + new FakeRegion( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.RETIRED_VERSION, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD, + OBSERVATION)); + + CacheResult reloaded = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)) + .getOrLoad( + "key", reloadable, loader(loads, new SourceLoadOutcome.Loaded<>("value", "rev-2"))); + + assertThat(reloaded) + .isEqualTo( + new CacheResult.LoadedFromSource<>("value", "rev-2", CacheRecordOutcome.RECORDED)); + assertThat(reloadable.recordIntents).containsExactly(CacheRecordIntent.ONLY_IF_OBSERVED); + assertThat(reloadable.observedTokens).containsExactly(OBSERVATION); + assertThat(loads).hasValue(1); + + FakeRegion unobservable = + new FakeRegion( + new CacheLookup.IncompatibleSchema<>( + CacheLookup.SchemaCategory.RETIRED_VERSION, + CacheLookup.SchemaPolicy.QUARANTINE_AND_RELOAD)); + + CacheResult refused = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)) + .getOrLoad( + "key", + unobservable, + loader(loads, new SourceLoadOutcome.Loaded<>("must-not-load", "rev-3"))); + + assertThat(refused) + .isEqualTo( + new CacheResult.IncompatibleSchema<>( + CacheLookup.SchemaCategory.RETIRED_VERSION, CacheLookup.SchemaPolicy.FAIL_FAST)); + assertThat(loads).hasValue(1); + } + + @Test + void propagatesAnUnclassifiedExceptionAsTheSameInstanceAndCleansTheFlight() { + CacheAsideExecutor executor = + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)); + FakeRegion region = new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + IllegalStateException original = new IllegalStateException("do not translate"); + + assertThatThrownBy( + () -> + executor.getOrLoad( + "key", + region, + (key, cancellation) -> { + throw original; + })) + .isSameAs(original); + + assertThat( + executor.getOrLoad( + "key", + region, + (key, cancellation) -> new SourceLoadOutcome.Loaded<>("recovered", "rev-2"))) + .isEqualTo( + new CacheResult.LoadedFromSource<>("recovered", "rev-2", CacheRecordOutcome.RECORDED)); + } + + @Test + void concurrentSameKeyMissesInvokeTheLoaderOnce() throws Exception { + CacheAsideExecutor executor = executor(policy(), Clock.systemUTC()); + FakeRegion region = new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + AtomicInteger loads = new AtomicInteger(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List>> futures = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + futures.add( + pool.submit( + () -> + executor.getOrLoad( + "same-key", + region, + (key, cancellation) -> { + loads.incrementAndGet(); + entered.countDown(); + await(release); + return new SourceLoadOutcome.Loaded<>("value", "rev-1"); + }))); + } + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + awaitWaiterCount(executor, "same-key", 3); + release.countDown(); + + for (Future> future : futures) { + assertThat(future.get(5, TimeUnit.SECONDS)) + .isEqualTo( + new CacheResult.LoadedFromSource<>("value", "rev-1", CacheRecordOutcome.RECORDED)); + } + assertThat(loads).hasValue(1); + assertThat(region.positiveRecords).containsExactly("same-key"); + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void loadDeadlineRejectsLateResultsWithoutRecordingThem() { + MutableClock clock = new MutableClock(NOW); + CacheAsideExecutor executor = executor(policy(), clock); + FakeRegion region = new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)); + + CacheResult result = + executor.getOrLoad( + "key", + region, + (key, cancellation) -> { + assertThat(cancellation.deadline()).isEqualTo(NOW.plusSeconds(5)); + clock.advance(Duration.ofSeconds(6)); + return new SourceLoadOutcome.Loaded<>("late", "rev-1"); + }); + + assertThat(result) + .isEqualTo(new CacheResult.Rejected<>(CacheResult.RejectionReason.LOAD_TIMEOUT)); + assertThat(region.positiveRecords).isEmpty(); + } + + @Test + void invalidationDuringLoadRejectsTheOldCapturedWriteCondition() throws Exception { + CacheWriteCondition initiallyCaptured = new CacheWriteCondition("generation-a.revision-a"); + CacheWriteCondition afterInvalidation = new CacheWriteCondition("generation-a.revision-b"); + GenerationGuardedFakeRegion region = + new GenerationGuardedFakeRegion(initiallyCaptured, afterInvalidation); + CountDownLatch sourceEntered = new CountDownLatch(1); + CountDownLatch releaseSource = new CountDownLatch(1); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future> result = + pool.submit( + () -> + executor(policy(), Clock.fixed(NOW, ZoneOffset.UTC)) + .getOrLoad( + "key", + region, + (key, cancellation) -> { + sourceEntered.countDown(); + await(releaseSource); + return new SourceLoadOutcome.Loaded<>("old-source-value", "opaque-rev"); + })); + + assertThat(sourceEntered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(region.invalidate("key")).isEqualTo(CacheInvalidationOutcome.INVALIDATED); + releaseSource.countDown(); + + assertThat(result.get(5, TimeUnit.SECONDS)) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "old-source-value", "opaque-rev", CacheRecordOutcome.NOT_RECORDED_CONDITION)); + assertThat(region.recordedConditions).containsExactly(initiallyCaptured); + assertThat(region.visibleValue).isNull(); + assertThat(region.currentCondition).isEqualTo(afterInvalidation); + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale() throws Exception { + SharedCoordinator coordinator = new SharedCoordinator(); + CacheRefreshCoordinationPolicy refreshPolicy = refreshPolicy(); + CacheAsideExecutor first = + new CacheAsideExecutor<>(policy(), Clock.systemUTC(), coordinator, refreshPolicy); + CacheAsideExecutor second = + new CacheAsideExecutor<>(policy(), Clock.systemUTC(), coordinator, refreshPolicy); + Instant now = Instant.now(); + FakeRegion stale = + new FakeRegion( + new CacheLookup.Hit<>( + "stale", + CacheLookup.Freshness.STALE, + "rev-1", + now.minusSeconds(1), + now.plusSeconds(30), + OBSERVATION)); + CountDownLatch ownerEntered = new CountDownLatch(1); + CountDownLatch releaseOwner = new CountDownLatch(1); + AtomicInteger loads = new AtomicInteger(); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future> owner = + pool.submit( + () -> + first.getOrLoad( + "key", + stale, + (key, cancellation) -> { + loads.incrementAndGet(); + ownerEntered.countDown(); + await(releaseOwner); + return new SourceLoadOutcome.Loaded<>("refreshed", "rev-2"); + })); + assertThat(ownerEntered.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat( + second.getOrLoad( + "key", + stale, + loader(loads, new SourceLoadOutcome.Loaded<>("must-not-load", "rev-contender")))) + .isEqualTo( + new CacheResult.StaleRefreshDeferred<>( + "stale", "rev-1", CacheResult.RefreshDeferralReason.CONTENDED)); + + releaseOwner.countDown(); + assertThat(owner.get(5, TimeUnit.SECONDS)) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "refreshed", "rev-2", CacheRecordOutcome.RECORDED)); + assertThat(loads).hasValue(1); + assertThat(coordinator.maximumOwners).hasValue(1); + } finally { + releaseOwner.countDown(); + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void localSingleFlightBoundsRefreshClaimsAndSourceLoadsToOnePerKey() throws Exception { + SharedCoordinator coordinator = new SharedCoordinator(); + CacheAsideExecutor executor = + new CacheAsideExecutor<>(policy(), Clock.systemUTC(), coordinator, refreshPolicy()); + Instant now = Instant.now(); + FakeRegion stale = + new FakeRegion( + new CacheLookup.Hit<>( + "stale", + CacheLookup.Freshness.STALE, + "rev-1", + now.minusSeconds(1), + now.plusSeconds(30), + OBSERVATION)); + AtomicInteger loads = new AtomicInteger(); + CountDownLatch ownerEntered = new CountDownLatch(1); + CountDownLatch releaseOwner = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List>> results = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + results.add( + pool.submit( + () -> + executor.getOrLoad( + "key", + stale, + (key, cancellation) -> { + loads.incrementAndGet(); + ownerEntered.countDown(); + await(releaseOwner); + return new SourceLoadOutcome.Loaded<>("refreshed", "rev-2"); + }))); + } + assertThat(ownerEntered.await(5, TimeUnit.SECONDS)).isTrue(); + awaitWaiterCount(executor, "key", 3); + releaseOwner.countDown(); + + for (Future> result : results) { + assertThat(result.get(5, TimeUnit.SECONDS)) + .isEqualTo( + new CacheResult.LoadedFromSource<>( + "refreshed", "rev-2", CacheRecordOutcome.RECORDED)); + } + assertThat(loads).hasValue(1); + assertThat(coordinator.sequences).hasValue(1); + assertThat(coordinator.maximumOwners).hasValue(1); + } finally { + releaseOwner.countDown(); + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void ownerDoubleChecksAndSkipsTheSourceWhenAnotherPodAlreadyRefreshed() { + SequencedRegion region = + new SequencedRegion( + new CacheLookup.Hit<>( + "stale", + CacheLookup.Freshness.STALE, + "rev-1", + NOW.minusSeconds(1), + HARD_EXPIRES_AT, + OBSERVATION), + new CacheLookup.Hit<>( + "fresh", + CacheLookup.Freshness.FRESH, + "rev-2", + NOW.plusSeconds(30), + NOW.plusSeconds(60))); + SharedCoordinator coordinator = new SharedCoordinator(); + AtomicInteger loads = new AtomicInteger(); + + CacheResult result = + new CacheAsideExecutor( + policy(), Clock.fixed(NOW, ZoneOffset.UTC), coordinator, refreshPolicy()) + .getOrLoad( + "key", + region, + loader(loads, new SourceLoadOutcome.Loaded<>("must-not-load", "rev-3"))); + + assertThat(result).isEqualTo(new CacheResult.FreshHit<>("fresh", "rev-2")); + assertThat(loads).hasValue(0); + assertThat(coordinator.releases).hasValue(1); + } + + @Test + void hardMissNormalLoadDoesNotUseTheSoftLease() { + CountingCoordinator coordinator = new CountingCoordinator(); + CacheResult result = + new CacheAsideExecutor( + policy(), Clock.fixed(NOW, ZoneOffset.UTC), coordinator, refreshPolicy()) + .getOrLoad( + "key", + new FakeRegion(new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT)), + (key, cancellation) -> new SourceLoadOutcome.Loaded<>("source", "rev-1")); + + assertThat(result) + .isEqualTo( + new CacheResult.LoadedFromSource<>("source", "rev-1", CacheRecordOutcome.RECORDED)); + assertThat(coordinator.calls).hasValue(0); + } + + @Test + void hardMissBoundedWaitObservesAnotherPodsFillBeforeFallingBackToSource() { + ContendedCoordinator coordinator = new ContendedCoordinator(); + SequencedRegion region = + new SequencedRegion( + new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT), + new CacheLookup.Hit<>( + "filled-by-owner", + CacheLookup.Freshness.FRESH, + "rev-2", + NOW.plusSeconds(30), + NOW.plusSeconds(60))); + AtomicInteger loads = new AtomicInteger(); + CacheRefreshCoordinationPolicy boundedWait = + new CacheRefreshCoordinationPolicy( + Duration.ofSeconds(10), + CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD, + Duration.ofMillis(1)); + + CacheResult result = + new CacheAsideExecutor( + policy(), Clock.fixed(NOW, ZoneOffset.UTC), coordinator, boundedWait) + .getOrLoad( + "key", + region, + loader(loads, new SourceLoadOutcome.Loaded<>("must-not-load", "rev-3"))); + + assertThat(result).isEqualTo(new CacheResult.FreshHit<>("filled-by-owner", "rev-2")); + assertThat(loads).hasValue(0); + assertThat(coordinator.claims).hasValue(1); + } + + @Test + void indeterminateClaimIsRetriedOnceWithTheSameOperation() { + IndeterminateThenOwnedCoordinator coordinator = new IndeterminateThenOwnedCoordinator(); + FakeRegion stale = + new FakeRegion( + new CacheLookup.Hit<>( + "stale", + CacheLookup.Freshness.STALE, + "rev-1", + NOW.minusSeconds(1), + HARD_EXPIRES_AT, + OBSERVATION)); + + CacheResult result = + new CacheAsideExecutor( + policy(), Clock.fixed(NOW, ZoneOffset.UTC), coordinator, refreshPolicy()) + .getOrLoad( + "key", + stale, + (key, cancellation) -> new SourceLoadOutcome.Loaded<>("refreshed", "rev-2")); + + assertThat(result) + .isEqualTo( + new CacheResult.LoadedFromSource<>("refreshed", "rev-2", CacheRecordOutcome.RECORDED)); + assertThat(coordinator.attempts).hasSize(2); + assertThat(coordinator.attempts.get(0)).isSameAs(coordinator.attempts.get(1)); + } + + private static CacheRefreshCoordinationPolicy refreshPolicy() { + return new CacheRefreshCoordinationPolicy( + Duration.ofSeconds(10), + CacheRefreshCoordinationPolicy.HardMissPolicy.NORMAL_SOURCE_LOAD, + Duration.ZERO); + } + + private static CacheAsidePolicy policy() { + return new CacheAsidePolicy(16, 8, 4, Duration.ofMillis(100), Duration.ofSeconds(5), true); + } + + private static CacheAsideExecutor executor(CacheAsidePolicy policy, Clock clock) { + return new CacheAsideExecutor<>(policy, clock); + } + + private static CacheSourceLoader loader( + AtomicInteger loads, SourceLoadOutcome outcome) { + return (key, cancellation) -> { + loads.incrementAndGet(); + return outcome; + }; + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + + private static void awaitWaiterCount( + CacheAsideExecutor executor, String key, int expected) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (executor.inFlightWaiterCount(key) != expected && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertThat(executor.inFlightWaiterCount(key)).isEqualTo(expected); + } + + private static final class FakeRegion implements CacheRegionPort { + + private final CacheLookup lookup; + private final List positiveRecords = new ArrayList<>(); + private final List negativeRecords = new ArrayList<>(); + private final List recordIntents = new ArrayList<>(); + private final List observedTokens = new ArrayList<>(); + private CacheInvalidationOutcome invalidationOutcome = CacheInvalidationOutcome.INVALIDATED; + + private FakeRegion(CacheLookup lookup) { + this.lookup = lookup; + } + + @Override + public CacheLookup lookup(String key) { + return lookup; + } + + @Override + public synchronized CacheRecordOutcome record( + String key, String value, CacheRecordMetadata metadata) { + positiveRecords.add(key); + recordIntents.add(metadata.intent()); + observedTokens.add(metadata.observedToken()); + return CacheRecordOutcome.RECORDED; + } + + @Override + public synchronized CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + negativeRecords.add(key); + recordIntents.add(metadata.intent()); + observedTokens.add(metadata.observedToken()); + return CacheRecordOutcome.RECORDED; + } + + @Override + public synchronized CacheInvalidationOutcome invalidate(String key) { + return invalidationOutcome; + } + + @Override + public CacheInvalidationOutcome invalidateRegion() { + return invalidationOutcome; + } + } + + private static final class GenerationGuardedFakeRegion + implements CacheRegionPort { + + private final CacheWriteCondition invalidatedCondition; + private final List recordedConditions = new ArrayList<>(); + private volatile CacheWriteCondition currentCondition; + private volatile String visibleValue; + + private GenerationGuardedFakeRegion( + CacheWriteCondition initiallyCaptured, CacheWriteCondition invalidatedCondition) { + this.currentCondition = initiallyCaptured; + this.invalidatedCondition = invalidatedCondition; + } + + @Override + public CacheLookup lookup(String key) { + return new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT, currentCondition); + } + + @Override + public synchronized CacheRecordOutcome record( + String key, String value, CacheRecordMetadata metadata) { + recordedConditions.add(metadata.writeCondition()); + if (!metadata.writeCondition().equals(currentCondition)) { + return CacheRecordOutcome.NOT_RECORDED_CONDITION; + } + visibleValue = value; + return CacheRecordOutcome.RECORDED; + } + + @Override + public CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + return record(key, reason.name(), metadata); + } + + @Override + public synchronized CacheInvalidationOutcome invalidate(String key) { + currentCondition = invalidatedCondition; + visibleValue = null; + return CacheInvalidationOutcome.INVALIDATED; + } + + @Override + public CacheInvalidationOutcome invalidateRegion() { + return invalidate("*"); + } + } + + private static final class SequencedRegion implements CacheRegionPort { + + private final CacheLookup first; + private final CacheLookup subsequent; + private final AtomicInteger lookups = new AtomicInteger(); + + private SequencedRegion(CacheLookup first, CacheLookup subsequent) { + this.first = first; + this.subsequent = subsequent; + } + + @Override + public CacheLookup lookup(String key) { + return lookups.getAndIncrement() == 0 ? first : subsequent; + } + + @Override + public CacheRecordOutcome record(String key, String value, CacheRecordMetadata metadata) { + return CacheRecordOutcome.RECORDED; + } + + @Override + public CacheRecordOutcome recordAbsent( + String key, AuthoritativeAbsence reason, CacheRecordMetadata metadata) { + return CacheRecordOutcome.RECORDED; + } + + @Override + public CacheInvalidationOutcome invalidate(String key) { + return CacheInvalidationOutcome.INVALIDATED; + } + + @Override + public CacheInvalidationOutcome invalidateRegion() { + return CacheInvalidationOutcome.INVALIDATED; + } + } + + private static final class SharedCoordinator implements CacheRefreshCoordinationPort { + + private final AtomicInteger sequences = new AtomicInteger(); + private final AtomicReference owner = new AtomicReference<>(); + private final AtomicInteger owners = new AtomicInteger(); + private final AtomicInteger maximumOwners = new AtomicInteger(); + private final AtomicInteger releases = new AtomicInteger(); + + @Override + public CacheRefreshClaimAttempt newAttempt() { + int sequence = sequences.incrementAndGet(); + return new CacheRefreshClaimAttempt( + new CacheRefreshOwnerToken("owner-token-%011d".formatted(sequence)), + new CacheRefreshOperationToken("operation-%012d".formatted(sequence))); + } + + @Override + public CacheRefreshClaimOutcome claim( + String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + CacheRefreshClaimAttempt current = owner.get(); + if (current != null) { + return current.equals(attempt) + ? new CacheRefreshClaimOutcome.AlreadyOwned(attempt) + : new CacheRefreshClaimOutcome.Contended(); + } + if (owner.compareAndSet(null, attempt)) { + int active = owners.incrementAndGet(); + maximumOwners.accumulateAndGet(active, Math::max); + return new CacheRefreshClaimOutcome.Claimed(attempt); + } + return new CacheRefreshClaimOutcome.Contended(); + } + + @Override + public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) { + if (!owner.compareAndSet(attempt, null)) { + return new CacheRefreshReleaseOutcome.NotOwner(); + } + owners.decrementAndGet(); + releases.incrementAndGet(); + return new CacheRefreshReleaseOutcome.Released(); + } + } + + private static final class CountingCoordinator implements CacheRefreshCoordinationPort { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public CacheRefreshClaimAttempt newAttempt() { + calls.incrementAndGet(); + return CacheRefreshClaimAttempt.unavailable(); + } + + @Override + public CacheRefreshClaimOutcome claim( + String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + calls.incrementAndGet(); + return new CacheRefreshClaimOutcome.Disabled(); + } + + @Override + public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) { + calls.incrementAndGet(); + return new CacheRefreshReleaseOutcome.Disabled(); + } + } + + private static final class IndeterminateThenOwnedCoordinator + implements CacheRefreshCoordinationPort { + + private final List attempts = new ArrayList<>(); + private final AtomicInteger claims = new AtomicInteger(); + + @Override + public CacheRefreshClaimAttempt newAttempt() { + return new CacheRefreshClaimAttempt( + new CacheRefreshOwnerToken("AAAAAAAAAAAAAAAAAAAAAA"), + new CacheRefreshOperationToken("BBBBBBBBBBBBBBBBBBBBBB")); + } + + @Override + public CacheRefreshClaimOutcome claim( + String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + attempts.add(attempt); + return claims.getAndIncrement() == 0 + ? new CacheRefreshClaimOutcome.Indeterminate() + : new CacheRefreshClaimOutcome.AlreadyOwned(attempt); + } + + @Override + public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) { + return new CacheRefreshReleaseOutcome.Released(); + } + } + + private static final class ContendedCoordinator implements CacheRefreshCoordinationPort { + + private final AtomicInteger claims = new AtomicInteger(); + + @Override + public CacheRefreshClaimAttempt newAttempt() { + return new CacheRefreshClaimAttempt( + new CacheRefreshOwnerToken("CCCCCCCCCCCCCCCCCCCCCC"), + new CacheRefreshOperationToken("DDDDDDDDDDDDDDDDDDDDDD")); + } + + @Override + public CacheRefreshClaimOutcome claim( + String key, CacheRefreshClaimAttempt attempt, Duration leaseTimeToLive) { + claims.incrementAndGet(); + return new CacheRefreshClaimOutcome.Contended(); + } + + @Override + public CacheRefreshReleaseOutcome release(String key, CacheRefreshClaimAttempt attempt) { + return new CacheRefreshReleaseOutcome.NotOwner(); + } + } + + private static final class MutableClock extends Clock { + + private Instant now; + + private MutableClock(Instant now) { + this.now = now; + } + + private void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheObservationContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheObservationContractTest.java new file mode 100644 index 0000000..5f70b4f --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheObservationContractTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.application.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class CacheObservationContractTest { + + @Test + void observationEventsCarryOnlyBoundedLowCardinalityDimensions() { + CacheObservationEvent.Lookup lookup = + new CacheObservationEvent.Lookup( + "worklog-summary", + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.HIT, + Duration.ofSeconds(2)); + CacheObservationEvent.LocalMaintenance maintenance = + new CacheObservationEvent.LocalMaintenance( + "worklog-summary", + CacheObservationEvent.MaintenanceAction.RECONCILE, + CacheObservationEvent.MaintenanceResult.FLUSHED, + CacheObservationEvent.MaintenanceCause.GENERATION_CHANGED, + 7); + + assertThat(lookup.cacheName()).isEqualTo("worklog-summary"); + assertThat(lookup.entryAge()).isEqualTo(Duration.ofSeconds(2)); + assertThat(maintenance.affectedEntries()).isEqualTo(7); + assertThat(lookup.toString()).doesNotContain("semantic-key"); + } + + @Test + void observationEventsRejectHighCardinalityOrUnboundedValues() { + assertThatThrownBy( + () -> + new CacheObservationEvent.Lookup( + "WorkLog/42", + CacheObservationEvent.Tier.LOCAL_L1, + CacheObservationEvent.LookupResult.HIT, + Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cacheName"); + + assertThatThrownBy( + () -> + new CacheObservationEvent.LocalMaintenance( + "worklog", + CacheObservationEvent.MaintenanceAction.EVICT, + CacheObservationEvent.MaintenanceResult.SUCCESS, + CacheObservationEvent.MaintenanceCause.TTL, + -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("affectedEntries"); + } + + @Test + void disabledObservationPortIsReusableAndHasNoFrameworkDependency() { + CacheObservationPort first = DisabledCacheObservationPort.instance(); + CacheObservationPort second = DisabledCacheObservationPort.instance(); + + first.observe( + new CacheObservationEvent.Lookup( + "worklog", + CacheObservationEvent.Tier.REDIS_L2, + CacheObservationEvent.LookupResult.MISS, + Duration.ZERO)); + + assertThat(first).isSameAs(second); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRefreshCoordinationContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRefreshCoordinationContractTest.java new file mode 100644 index 0000000..c56877f --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRefreshCoordinationContractTest.java @@ -0,0 +1,75 @@ +package dev.caskeleton.application.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class CacheRefreshCoordinationContractTest { + + @Test + void opaqueAttemptTokensAreBoundedAndRedacted() { + CacheRefreshOwnerToken owner = new CacheRefreshOwnerToken("AAAAAAAAAAAAAAAAAAAAAA"); + CacheRefreshOperationToken operation = new CacheRefreshOperationToken("BBBBBBBBBBBBBBBBBBBBBB"); + CacheRefreshClaimAttempt attempt = new CacheRefreshClaimAttempt(owner, operation); + + assertThat(attempt.usable()).isTrue(); + assertThat(attempt.toString()).doesNotContain(owner.value()).doesNotContain(operation.value()); + assertThatThrownBy(() -> new CacheRefreshOwnerToken("short")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CacheRefreshOperationToken("contains spaces here")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void disabledCoordinatorIsExplicitAndHasNoOwnershipSideEffects() { + CacheRefreshCoordinationPort disabled = DisabledCacheRefreshCoordinationPort.instance(); + CacheRefreshClaimAttempt attempt = disabled.newAttempt(); + + assertThat(disabled.enabled()).isFalse(); + assertThat(attempt.usable()).isFalse(); + assertThat(disabled.claim("key", attempt, Duration.ofSeconds(10))) + .isEqualTo(new CacheRefreshClaimOutcome.Disabled()); + assertThat(disabled.release("key", attempt)) + .isEqualTo(new CacheRefreshReleaseOutcome.Disabled()); + } + + @Test + void policyRequiresAFiniteLeaseBeyondTheSourceDeadlineAndMakesHardMissBehaviorExplicit() { + CacheAsidePolicy cacheAside = + new CacheAsidePolicy(16, 8, 4, Duration.ofMillis(100), Duration.ofSeconds(5), true); + + assertThatThrownBy( + () -> + new CacheRefreshCoordinationPolicy( + Duration.ofSeconds(5), + CacheRefreshCoordinationPolicy.HardMissPolicy.NORMAL_SOURCE_LOAD, + Duration.ZERO) + .validateAgainst(cacheAside)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("source load deadline"); + assertThatThrownBy( + () -> + new CacheRefreshCoordinationPolicy( + Duration.ofMinutes(5).plusMillis(1), + CacheRefreshCoordinationPolicy.HardMissPolicy.NORMAL_SOURCE_LOAD, + Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("5 minutes"); + + CacheRefreshCoordinationPolicy normal = + new CacheRefreshCoordinationPolicy( + Duration.ofSeconds(10), + CacheRefreshCoordinationPolicy.HardMissPolicy.NORMAL_SOURCE_LOAD, + Duration.ZERO); + CacheRefreshCoordinationPolicy boundedWait = + new CacheRefreshCoordinationPolicy( + Duration.ofSeconds(10), + CacheRefreshCoordinationPolicy.HardMissPolicy.BOUNDED_WAIT_THEN_SOURCE_LOAD, + Duration.ofMillis(25)); + + assertThat(normal.validateAgainst(cacheAside)).isSameAs(normal); + assertThat(boundedWait.validateAgainst(cacheAside)).isSameAs(boundedWait); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java index 3874718..1b73a79 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheRegionContractTest.java @@ -3,14 +3,19 @@ package dev.caskeleton.application.cache; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.Instant; import org.junit.jupiter.api.Test; class CacheRegionContractTest { + private static final Instant SOFT_EXPIRES_AT = Instant.parse("2026-07-28T01:00:00Z"); + private static final Instant HARD_EXPIRES_AT = Instant.parse("2026-07-28T01:05:00Z"); + @Test void keepsMissNegativeHitAndUnavailableDistinct() { CacheLookup miss = new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT); - CacheLookup negative = new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND); + CacheLookup negative = + new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND, HARD_EXPIRES_AT); CacheLookup unavailable = new CacheLookup.Unavailable<>( CacheLookup.UnavailabilityReason.OVERLOADED, @@ -23,12 +28,65 @@ class CacheRegionContractTest { @Test void hitCarriesFreshnessAndSourceRevisionWithoutProviderTypes() { + CacheWriteCondition writeCondition = + new CacheWriteCondition("captured-generation-and-revision"); CacheLookup.Hit hit = - new CacheLookup.Hit<>("snapshot", CacheLookup.Freshness.STALE, "source-42"); + new CacheLookup.Hit<>( + "snapshot", + CacheLookup.Freshness.STALE, + "source-42", + SOFT_EXPIRES_AT, + HARD_EXPIRES_AT, + CacheObservationToken.unavailable(), + writeCondition); assertThat(hit.value()).isEqualTo("snapshot"); assertThat(hit.freshness()).isEqualTo(CacheLookup.Freshness.STALE); assertThat(hit.sourceRevision()).isEqualTo("source-42"); + assertThat(hit.softExpiresAt()).isEqualTo(SOFT_EXPIRES_AT); + assertThat(hit.hardExpiresAt()).isEqualTo(HARD_EXPIRES_AT); + assertThat(hit.writeCondition()).isEqualTo(writeCondition); + } + + @Test + void missCarriesAnOpaqueCapturedWriteConditionWithoutRedisTypes() { + CacheWriteCondition condition = new CacheWriteCondition("generation-7.key-revision-11"); + + CacheLookup.Miss miss = + new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT, condition); + CacheRecordMetadata metadata = + new CacheRecordMetadata( + "opaque-source-revision", + CacheRecordIntent.ONLY_IF_ABSENT, + CacheObservationToken.unavailable(), + condition); + + assertThat(miss.writeCondition()).isEqualTo(condition); + assertThat(metadata.writeCondition()).isEqualTo(condition); + assertThat(condition.toString()).doesNotContain("generation-7"); + } + + @Test + void writeConditionHasABoundedOpaqueValueAndAnExplicitUnavailableSentinel() { + assertThat(CacheWriteCondition.unavailable().usable()).isFalse(); + assertThat(new CacheWriteCondition("opaque-token").usable()).isTrue(); + assertThatThrownBy(() -> new CacheWriteCondition("x".repeat(513))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1..512"); + } + + @Test + void hitRejectsAnExpiryWindowWithSoftAfterHard() { + assertThatThrownBy( + () -> + new CacheLookup.Hit<>( + "snapshot", + CacheLookup.Freshness.FRESH, + "source-42", + HARD_EXPIRES_AT.plusSeconds(1), + HARD_EXPIRES_AT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("softExpiresAt"); } @Test @@ -37,4 +95,12 @@ class CacheRegionContractTest { () -> new CacheRecordMetadata(" ", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER)) .isInstanceOf(IllegalArgumentException.class); } + + @Test + void sourceFailureRenderingNeverLeaksTheOriginalCauseMessage() { + SourceFailure failure = + new SourceFailure("SOURCE_TIMEOUT", new IllegalStateException("sensitive upstream detail")); + + assertThat(failure.toString()).contains("SOURCE_TIMEOUT").doesNotContain("sensitive"); + } } diff --git a/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheResilienceConcurrencyTest.java b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheResilienceConcurrencyTest.java new file mode 100644 index 0000000..1d6ea2f --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/cache/CacheResilienceConcurrencyTest.java @@ -0,0 +1,200 @@ +package dev.caskeleton.application.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class CacheResilienceConcurrencyTest { + + @Test + void singleFlightBoundsInFlightKeysAndWaitersAndCleansCompletedFlights() throws Exception { + CacheSingleFlight flights = new CacheSingleFlight<>(1, 1); + CountDownLatch leaderEntered = new CountDownLatch(1); + CountDownLatch releaseLeader = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future> leader = + pool.submit( + () -> + flights.execute( + "first", + Duration.ofSeconds(5), + () -> { + leaderEntered.countDown(); + await(releaseLeader); + return "value"; + })); + assertThat(leaderEntered.await(5, TimeUnit.SECONDS)).isTrue(); + + Future> waiter = + pool.submit( + () -> flights.execute("first", Duration.ofSeconds(5), () -> "must-not-be-called")); + awaitWaiterCount(flights, "first", 1); + + assertThat(flights.execute("first", Duration.ZERO, () -> "must-not-be-called")) + .isEqualTo( + new CacheSingleFlight.Rejected<>(CacheSingleFlight.RejectionReason.MAXIMUM_WAITERS)); + assertThat(flights.execute("second", Duration.ZERO, () -> "must-not-be-called")) + .isEqualTo( + new CacheSingleFlight.Rejected<>( + CacheSingleFlight.RejectionReason.MAXIMUM_IN_FLIGHT_KEYS)); + + releaseLeader.countDown(); + assertThat(leader.get(5, TimeUnit.SECONDS)) + .isEqualTo(new CacheSingleFlight.Completed<>("value")); + assertThat(waiter.get(5, TimeUnit.SECONDS)) + .isEqualTo(new CacheSingleFlight.Completed<>("value")); + assertThat(flights.inFlightCount()).isZero(); + + assertThat(flights.execute("second", Duration.ZERO, () -> "next")) + .isEqualTo(new CacheSingleFlight.Completed<>("next")); + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void waiterTimeoutAndInterruptionAreFiniteAndPreserveTheInterruptFlag() throws Exception { + CacheSingleFlight flights = new CacheSingleFlight<>(1, 2); + CountDownLatch leaderEntered = new CountDownLatch(1); + CountDownLatch releaseLeader = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future leader = + pool.submit( + () -> + flights.execute( + "key", + Duration.ofSeconds(5), + () -> { + leaderEntered.countDown(); + await(releaseLeader); + return "value"; + })); + assertThat(leaderEntered.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(flights.execute("key", Duration.ZERO, () -> "must-not-run")) + .isEqualTo( + new CacheSingleFlight.Rejected<>(CacheSingleFlight.RejectionReason.WAIT_TIMEOUT)); + + Future interrupted = + pool.submit( + () -> { + Thread.currentThread().interrupt(); + CacheSingleFlight.Outcome result = + flights.execute("key", Duration.ofSeconds(1), () -> "must-not-run"); + return result instanceof CacheSingleFlight.Interrupted + && Thread.currentThread().isInterrupted(); + }); + assertThat(interrupted.get(5, TimeUnit.SECONDS)).isTrue(); + releaseLeader.countDown(); + leader.get(5, TimeUnit.SECONDS); + } finally { + releaseLeader.countDown(); + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void sourceBulkheadBoundsConcurrencyAndPreservesAdmissionInterruption() throws Exception { + CacheSourceBulkhead bulkhead = new CacheSourceBulkhead(1); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future> first = + pool.submit( + () -> + bulkhead.execute( + Duration.ZERO, + () -> { + entered.countDown(); + await(release); + return "value"; + })); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(bulkhead.execute(Duration.ZERO, () -> "must-not-run")) + .isEqualTo(new CacheSourceBulkhead.Rejected<>()); + + Future interrupted = + pool.submit( + () -> { + Thread.currentThread().interrupt(); + CacheSourceBulkhead.Outcome outcome = + bulkhead.execute(Duration.ofSeconds(1), () -> "must-not-run"); + return outcome instanceof CacheSourceBulkhead.Interrupted + && Thread.currentThread().isInterrupted(); + }); + assertThat(interrupted.get(5, TimeUnit.SECONDS)).isTrue(); + release.countDown(); + assertThat(first.get(5, TimeUnit.SECONDS)) + .isEqualTo(new CacheSourceBulkhead.Completed<>("value")); + } finally { + release.countDown(); + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void opportunisticallyReapsAnAbandonedFlightSoTheKeyBoundCannotLeakForever() throws Exception { + AtomicLong ticker = new AtomicLong(1_000L); + CacheSingleFlight flights = new CacheSingleFlight<>(1, 1, ticker::get); + CountDownLatch leaderEntered = new CountDownLatch(1); + CountDownLatch releaseLeader = new CountDownLatch(1); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future abandoned = + pool.submit( + () -> + flights.execute( + "abandoned", + Duration.ofNanos(10), + () -> { + leaderEntered.countDown(); + await(releaseLeader); + return "late"; + })); + assertThat(leaderEntered.await(5, TimeUnit.SECONDS)).isTrue(); + ticker.addAndGet(11L); + + assertThat(flights.execute("next", Duration.ofSeconds(1), () -> "accepted")) + .isEqualTo(new CacheSingleFlight.Completed<>("accepted")); + + releaseLeader.countDown(); + abandoned.get(5, TimeUnit.SECONDS); + assertThat(flights.inFlightCount()).isZero(); + } finally { + releaseLeader.countDown(); + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private static void awaitWaiterCount( + CacheSingleFlight flights, String key, int expected) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (flights.waiterCount(key) != expected && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertThat(flights.waiterCount(key)).isEqualTo(expected); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java index 573417e..ee75c30 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java @@ -3,7 +3,10 @@ package dev.caskeleton.application.filepublication; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.Instant; +import java.util.Arrays; import java.util.List; +import java.util.Locale; import org.junit.jupiter.api.Test; class FilePublicationContractTest { @@ -96,4 +99,38 @@ class FilePublicationContractTest { assertThat(request.formatProfileId()).isEqualTo("csv-rfc4180-v1"); } + + @Test + void receiptReportsFileAndDirectorySyncWithoutProviderTypes() { + FilePublishReceipt receipt = + new FilePublishReceipt( + new FilePublishOperationId("01J1234567890ABCDEFGHJKMNP"), + new PublishedFileReference("publication-42"), + new FileDestinationId("local-export"), + "worklogs.csv", + new FileVersion("version-42"), + "csv-rfc4180-v1", + "text/csv", + "UTF-8", + 128, + 4, + 3, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + Instant.parse("2026-07-28T00:00:00Z"), + FilePublishReceipt.PublicationGuarantee.UNIQUE_ATOMIC_CREATE, + FilePublishReceipt.DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC, + 0); + + assertThat(receipt.durabilityGuarantee()) + .isEqualTo(FilePublishReceipt.DurabilityGuarantee.FILE_AND_DIRECTORY_SYNC); + assertThat( + Arrays.stream(FilePublishReceipt.class.getRecordComponents()) + .map(component -> component.getGenericType().getTypeName()) + .map(typeName -> typeName.toLowerCase(Locale.ROOT))) + .noneMatch( + typeName -> + typeName.startsWith("java.nio.file.") + || typeName.contains("fileserver") + || typeName.contains("sftp")); + } } diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2Test.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2Test.java new file mode 100644 index 0000000..a2359e3 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorV2Test.java @@ -0,0 +1,286 @@ +package dev.caskeleton.application.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class IdempotencyExecutorV2Test { + + private static final String OWNER = "owner_token_1234567890"; + private static final String OPERATION = "operation_token_12345"; + private static final IdempotencyScope SCOPE = + IdempotencyScope.of("principal", "request", "create-worklog"); + private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("a".repeat(64)); + private static final IdempotencyClaimAttempt ATTEMPT = + new IdempotencyClaimAttempt(OWNER, OPERATION); + + @Test + void actionRunsOnlyAfterANewlyConfirmedStartAndThenCompletes() { + FakeStore store = new FakeStore(); + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 1); + store.claim = + new IdempotencyClaimOutcome.Acquired(owner, Instant.parse("2026-07-29T12:00:30Z")); + store.start = new IdempotencyStartOutcome(IdempotencyStartOutcome.Status.STARTED, null); + 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(); + assertThat(store.releaseCalls).isZero(); + } + + @Test + void exactSameOperationStartReplayOrInspectionConfirmsTheStartBeforeRunning() { + FakeStore store = new FakeStore(); + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 1); + store.claim = new IdempotencyClaimOutcome.Indeterminate(OPERATION); + store.inspection = + new IdempotencyInspection.ExecutingSameOperation( + owner, Instant.parse("2026-07-29T12:00:30Z")); + 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).isZero(); + + ran.set(false); + store.claim = + new IdempotencyClaimOutcome.ReplayedAcquire(owner, Instant.parse("2026-07-29T12:00:30Z")); + store.start = + new IdempotencyStartOutcome( + IdempotencyStartOutcome.Status.ALREADY_STARTED_SAME_OPERATION, null); + assertThat( + executor(store) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> { + ran.set(true); + return new IdempotentAction.Outcome.Success<>("created-again"); + }, + codec())) + .isEqualTo("created-again"); + assertThat(ran).isTrue(); + } + + @Test + void completionResponseLossIsInspectedWithoutRunningTheActionAgain() { + FakeStore store = startedStore(); + store.complete = + new IdempotencyCompleteOutcome(IdempotencyCompleteOutcome.Status.INDETERMINATE, OPERATION); + store.inspectionAfterComplete = + new IdempotencyInspection.CompletedReplay( + new StoredResponse("created"), 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 + void unclassifiedExceptionsAndEffectUnknownAreNeverDiscardedAsNoEffect() { + FakeStore first = startedStore(); + RuntimeException unclassified = new IllegalStateException("unknown effect"); + + assertThatThrownBy( + () -> + executor(first) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> { + throw unclassified; + }, + codec())) + .isSameAs(unclassified); + assertThat(first.lastDisposition) + .isEqualTo(IdempotencyFailureDisposition.ABANDONED_EFFECT_UNKNOWN); + assertThat(first.releaseCalls).isZero(); + + FakeStore store = startedStore(); + RuntimeException classified = new IllegalArgumentException("provider response unknown"); + FakeStore second = store; + assertThatThrownBy( + () -> + executor(second) + .execute( + SCOPE, + FINGERPRINT, + ATTEMPT, + () -> new IdempotentAction.Outcome.EffectUnknown<>(classified), + codec())) + .isSameAs(classified); + assertThat(second.lastDisposition) + .isEqualTo(IdempotencyFailureDisposition.ABANDONED_EFFECT_UNKNOWN); + assertThat(second.releaseCalls).isZero(); + } + + @Test + void explicitlyNoEffectFailureIsTheOnlyRetryableFailurePath() { + 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.RETRYABLE_NO_EFFECT); + } + + private static FakeStore startedStore() { + FakeStore store = new FakeStore(); + store.claim = + new IdempotencyClaimOutcome.Acquired( + new IdempotencyOwner(SCOPE, OWNER, 1), Instant.parse("2026-07-29T12:00:30Z")); + store.start = new IdempotencyStartOutcome(IdempotencyStartOutcome.Status.STARTED, null); + return store; + } + + private static IdempotencyExecutorV2 executor(FakeStore store) { + return new IdempotencyExecutorV2( + store, + Duration.ofSeconds(30), + Duration.ofHours(1), + Duration.ofHours(1), + "json-v2", + "policy-v2"); + } + + 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 IdempotencyCompleteOutcome complete = + new IdempotencyCompleteOutcome(IdempotencyCompleteOutcome.Status.COMPLETED, null); + private IdempotencyInspection inspection = new IdempotencyInspection.Unavailable(); + private IdempotencyInspection inspectionAfterComplete; + private int startCalls; + private int completeCalls; + private int failedCalls; + private int releaseCalls; + private IdempotencyFailureDisposition lastDisposition; + + @Override + public IdempotencyClaimAttempt newClaimAttempt(String operationId) { + return new IdempotencyClaimAttempt(OWNER, operationId); + } + + @Override + public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) { + return claim; + } + + @Override + public IdempotencyStartOutcome markExecutionStarted( + IdempotencyOwner owner, String operationId) { + startCalls++; + return start; + } + + @Override + public IdempotencyRenewOutcome renew( + IdempotencyOwner owner, Duration processingLeaseTtl, String operationId) { + throw new UnsupportedOperationException(); + } + + @Override + public IdempotencyCompleteOutcome complete( + IdempotencyOwner owner, StoredResponse response, Duration replayTtl, String operationId) { + completeCalls++; + if (inspectionAfterComplete != null) { + inspection = inspectionAfterComplete; + } + return complete; + } + + @Override + public IdempotencyFailOutcome markFailed( + IdempotencyOwner owner, + IdempotencyFailureDisposition disposition, + Duration retention, + String operationId) { + failedCalls++; + lastDisposition = disposition; + return new IdempotencyFailOutcome( + disposition == IdempotencyFailureDisposition.RETRYABLE_NO_EFFECT + ? IdempotencyFailOutcome.Status.MARKED_RETRYABLE + : IdempotencyFailOutcome.Status.MARKED_ABANDONED, + null); + } + + @Override + public IdempotencyReleaseOutcome releaseBeforeExecution( + IdempotencyOwner owner, String operationId) { + releaseCalls++; + return new IdempotencyReleaseOutcome( + IdempotencyReleaseOutcome.Status.RELEASED_BEFORE_EXECUTION, null); + } + + @Override + public IdempotencyInspection inspect(IdempotencyInspectionRequest request) { + return inspection; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyV2ContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyV2ContractTest.java new file mode 100644 index 0000000..214d59f --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyV2ContractTest.java @@ -0,0 +1,162 @@ +package dev.caskeleton.application.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class IdempotencyV2ContractTest { + + private static final String OWNER = "owner_token_1234567890"; + private static final String OPERATION = "operation_token_12345"; + private static final IdempotencyScope SCOPE = + IdempotencyScope.of("principal-digest", "request-key", "create-worklog"); + private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("a".repeat(64)); + + @Test + void claimSeparatesProcessingAndReplayRetentionAndCarriesAPreallocatedAttempt() { + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyClaimRequest request = + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + attempt, + Duration.ofSeconds(30), + Duration.ofHours(24), + "json-v2", + "request-replay-v2"); + + assertThat(request.processingLeaseTtl()).isEqualTo(Duration.ofSeconds(30)); + assertThat(request.replayTtl()).isEqualTo(Duration.ofHours(24)); + assertThat(request.recoveryRetention()).isEqualTo(Duration.ofHours(24)); + assertThat(request.claimAttempt()).isSameAs(attempt); + assertThat(request.toString()).doesNotContain(OWNER).doesNotContain(OPERATION); + } + + @Test + void ownerAndOperationTokensAreBoundedOpaqueAndRedacted() { + IdempotencyClaimAttempt attempt = new IdempotencyClaimAttempt(OWNER, OPERATION); + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 3); + + assertThat(attempt.toString()).doesNotContain(OWNER).doesNotContain(OPERATION); + assertThat(owner.toString()).doesNotContain(OWNER).doesNotContain("request-key"); + assertThatThrownBy(() -> new IdempotencyClaimAttempt("short", OPERATION)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ownerToken"); + assertThatThrownBy(() -> new IdempotencyOwner(SCOPE, OWNER, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attempt"); + } + + @Test + void claimOutcomesCannotConfuseExpiredClaimedAndExpiredExecutingRecords() { + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 2); + Instant leaseUntil = Instant.parse("2026-07-29T08:00:30Z"); + + IdempotencyClaimOutcome acquired = new IdempotencyClaimOutcome.Acquired(owner, leaseUntil); + IdempotencyClaimOutcome takeover = + new IdempotencyClaimOutcome.TakenOverClaimed(owner, leaseUntil); + IdempotencyClaimOutcome recovery = new IdempotencyClaimOutcome.RecoveryRequired(1); + + assertThat(acquired).isInstanceOf(IdempotencyClaimOutcome.Acquired.class); + assertThat(takeover).isInstanceOf(IdempotencyClaimOutcome.TakenOverClaimed.class); + assertThat(recovery).isInstanceOf(IdempotencyClaimOutcome.RecoveryRequired.class); + } + + @Test + void completedReplayAndInProgressHaveBoundedTypedPayloads() { + Instant replayUntil = Instant.parse("2026-07-30T08:00:00Z"); + IdempotencyClaimOutcome completed = + new IdempotencyClaimOutcome.CompletedReplay( + new StoredResponse("{\"id\":\"42\"}"), replayUntil); + IdempotencyClaimOutcome inProgress = + new IdempotencyClaimOutcome.InProgress(Duration.ofMillis(250), 4); + + assertThat(((IdempotencyClaimOutcome.CompletedReplay) completed).replayUntil()) + .isEqualTo(replayUntil); + assertThat(((IdempotencyClaimOutcome.InProgress) inProgress).currentAttempt()).isEqualTo(4); + assertThatThrownBy(() -> new IdempotencyClaimOutcome.InProgress(Duration.ZERO, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("retryAfter"); + } + + @Test + void everyOwnerMutationHasExplicitConflictUnknownAndUnavailableResults() { + assertThat(IdempotencyStartOutcome.operationConflict().status()) + .isEqualTo(IdempotencyStartOutcome.Status.OPERATION_CONFLICT); + assertThat(IdempotencyRenewOutcome.indeterminate(OPERATION).status()) + .isEqualTo(IdempotencyRenewOutcome.Status.INDETERMINATE); + assertThat(IdempotencyCompleteOutcome.responseConflict().status()) + .isEqualTo(IdempotencyCompleteOutcome.Status.RESPONSE_CONFLICT); + assertThat(IdempotencyFailOutcome.unavailable().status()) + .isEqualTo(IdempotencyFailOutcome.Status.UNAVAILABLE); + assertThat(IdempotencyReleaseOutcome.executionAlreadyStarted().status()) + .isEqualTo(IdempotencyReleaseOutcome.Status.EXECUTION_ALREADY_STARTED); + } + + @Test + void inspectionKeepsSameOperationRecoverySeparateFromOtherOwnerAndMismatch() { + IdempotencyOwner owner = new IdempotencyOwner(SCOPE, OWNER, 1); + Instant leaseUntil = Instant.parse("2026-07-29T08:00:30Z"); + IdempotencyInspection sameOperation = + new IdempotencyInspection.ExecutingSameOperation(owner, leaseUntil); + IdempotencyInspection other = new IdempotencyInspection.InProgressOther(2); + IdempotencyInspection mismatch = new IdempotencyInspection.FingerprintMismatch(); + + assertThat(sameOperation).isInstanceOf(IdempotencyInspection.ExecutingSameOperation.class); + assertThat(other).isInstanceOf(IdempotencyInspection.InProgressOther.class); + assertThat(mismatch).isInstanceOf(IdempotencyInspection.FingerprintMismatch.class); + } + + @Test + void ttlCodecAndPolicyFieldsAreFiniteAndBounded() { + assertThatThrownBy( + () -> + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + new IdempotencyClaimAttempt(OWNER, OPERATION), + Duration.ZERO, + Duration.ofHours(1), + "json-v2", + "policy-v2")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("processingLeaseTtl"); + assertThatThrownBy( + () -> + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + new IdempotencyClaimAttempt(OWNER, OPERATION), + Duration.ofSeconds(30), + Duration.ofDays(31), + "json-v2", + "policy-v2")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("replayTtl"); + assertThatThrownBy( + () -> + new IdempotencyClaimRequest( + SCOPE, + FINGERPRINT, + new IdempotencyClaimAttempt(OWNER, OPERATION), + Duration.ofSeconds(30), + Duration.ofSeconds(30), + "json-v2", + "policy-v2")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("recovery retention"); + } + + @Test + void requestFingerprintRejectsNonHexAndUppercaseRepresentations() { + assertThatThrownBy(() -> new RequestFingerprint("z".repeat(64))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lowercase"); + assertThatThrownBy(() -> new RequestFingerprint("A".repeat(64))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lowercase"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/lease/DistributedLeaseV2ContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/lease/DistributedLeaseV2ContractTest.java new file mode 100644 index 0000000..fe72589 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/lease/DistributedLeaseV2ContractTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.application.lease; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class DistributedLeaseV2ContractTest { + + private static final String OWNER = "owner_token_1234567890"; + private static final String OPERATION = "operation_token_12345"; + private static final String RESOURCE = + "hv1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @Test + void callerPreallocatesABoundedRedactedAttemptBeforeAnyProviderCall() { + LeaseAttempt attempt = new LeaseAttempt(OWNER, OPERATION); + + assertThat(attempt.toString()).doesNotContain(OWNER).doesNotContain(OPERATION); + assertThatThrownBy(() -> new LeaseAttempt("short", OPERATION)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ownerToken"); + assertThatThrownBy(() -> new LeaseAttempt(OWNER, "contains spaces here")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationId"); + } + + @Test + void requestCarriesOnlyPurposeDigestAndFiniteWaitAndLeaseBounds() { + LeaseAttempt attempt = new LeaseAttempt(OWNER, OPERATION); + LeaseRequest request = + new LeaseRequest( + "cache-refresh", RESOURCE, Duration.ofMillis(250), Duration.ofSeconds(10), attempt); + + assertThat(request.purpose()).isEqualTo("cache-refresh"); + assertThat(request.resourceDigest()).isEqualTo(RESOURCE); + assertThat(request.toString()).doesNotContain(RESOURCE).doesNotContain(OWNER); + assertThatThrownBy( + () -> + new LeaseRequest( + "cache-refresh", + "raw/customer/42", + Duration.ZERO, + Duration.ofSeconds(1), + attempt)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("resourceDigest"); + assertThatThrownBy( + () -> + new LeaseRequest( + "cache-refresh", + RESOURCE, + Duration.ofMillis(-1), + Duration.ofSeconds(1), + attempt)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("waitTimeout"); + } + + @Test + void acquireAndInspectionKeepResponseLossDifferentFromUnavailableAndContention() { + LeaseHandle handle = new FakeHandle(); + + assertThat(new LeaseAcquireOutcome.Acquired(handle)) + .isInstanceOf(LeaseAcquireOutcome.Acquired.class); + assertThat(new LeaseAcquireOutcome.ReplayedSameOperation(handle)) + .isInstanceOf(LeaseAcquireOutcome.ReplayedSameOperation.class); + assertThat(new LeaseAcquireOutcome.Contended(Duration.ofMillis(25)).retryAfter()) + .isEqualTo(Duration.ofMillis(25)); + assertThat(new LeaseAcquireOutcome.Indeterminate(OPERATION).toString()) + .doesNotContain(OPERATION); + assertThat( + new LeaseAcquireOutcome.Unavailable(LeaseUnavailableCategory.UNAVAILABLE_BEFORE_SEND)) + .isInstanceOf(LeaseAcquireOutcome.Unavailable.class); + + LeaseInspectionRequest inspection = + new LeaseInspectionRequest("cache-refresh", RESOURCE, new LeaseAttempt(OWNER, OPERATION)); + assertThat(inspection.toString()).doesNotContain(RESOURCE).doesNotContain(OWNER); + assertThat(new LeaseInspectionOutcome.Owned(handle)) + .isInstanceOf(LeaseInspectionOutcome.Owned.class); + assertThat(new LeaseInspectionOutcome.NotOwner()) + .isInstanceOf(LeaseInspectionOutcome.NotOwner.class); + } + + @Test + void handleMakesValidityAndOwnerSafeMutationStateExplicit() { + LeaseHandle handle = new FakeHandle(); + + assertThat(handle).isInstanceOf(AutoCloseable.class); + assertThat(handle.guarantee()).isEqualTo(LeaseGuarantee.EFFICIENCY_ONLY); + assertThat(handle.state()).isEqualTo(LeaseState.ACTIVE); + assertThat(handle.isUsableFor(Duration.ofSeconds(4))).isTrue(); + assertThat(handle.isUsableFor(Duration.ofSeconds(6))).isFalse(); + assertThat(handle.renew(Duration.ofSeconds(10))) + .isEqualTo(new LeaseRenewOutcome.Renewed(Duration.ofSeconds(9))); + assertThat(handle.release()).isEqualTo(new LeaseReleaseOutcome.Released()); + handle.close(); + } + + private static final class FakeHandle implements LeaseHandle { + + @Override + public String ownerToken() { + return OWNER; + } + + @Override + public String operationId() { + return OPERATION; + } + + @Override + public Instant acquiredAt() { + return Instant.parse("2026-07-29T08:00:00Z"); + } + + @Override + public Duration remainingValidity() { + return Duration.ofSeconds(5); + } + + @Override + public Instant observedServerExpiry() { + return Instant.parse("2026-07-29T08:00:10Z"); + } + + @Override + public LeaseState state() { + return LeaseState.ACTIVE; + } + + @Override + public LeaseRenewOutcome renew(Duration leaseTtl) { + return new LeaseRenewOutcome.Renewed(Duration.ofSeconds(9)); + } + + @Override + public LeaseReleaseOutcome release() { + return new LeaseReleaseOutcome.Released(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/lease/LeaseWatchdogTest.java b/src/application-core/src/test/java/dev/caskeleton/application/lease/LeaseWatchdogTest.java new file mode 100644 index 0000000..52b141c --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/lease/LeaseWatchdogTest.java @@ -0,0 +1,132 @@ +package dev.caskeleton.application.lease; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class LeaseWatchdogTest { + + private static final Instant NOW = Instant.parse("2026-07-29T10:00:00Z"); + + @Test + void boundsRegistrationsAndCancelsWorkWhenRenewalBecomesUnknown() { + AtomicInteger cancelled = new AtomicInteger(); + AtomicInteger lostSignals = new AtomicInteger(); + FakeHandle handle = new FakeHandle(); + handle.renewOutcome = new LeaseRenewOutcome.Indeterminate("renew_operation_12345"); + + try (LeaseWatchdog watchdog = new LeaseWatchdog(1, 1, Clock.fixed(NOW, ZoneOffset.UTC))) { + LeaseWatchdog.Registration registration = + watchdog.watch( + handle, + Duration.ofSeconds(10), + Duration.ofSeconds(5), + NOW.plusSeconds(30), + cancelled::incrementAndGet, + state -> { + assertThat(state).isEqualTo(LeaseState.UNKNOWN); + lostSignals.incrementAndGet(); + }); + + assertThatThrownBy( + () -> + watchdog.watch( + new FakeHandle(), + Duration.ofSeconds(10), + Duration.ofSeconds(5), + NOW.plusSeconds(30), + () -> {}, + state -> {})) + .isInstanceOf(RejectedExecutionException.class); + + registration.runOnceForTest(); + registration.runOnceForTest(); + + assertThat(cancelled).hasValue(1); + assertThat(lostSignals).hasValue(1); + assertThat(registration.closed()).isTrue(); + assertThat(watchdog.activeRegistrations()).isZero(); + } + } + + @Test + void explicitCancellationRemovesTheScheduledRenewalWithoutReportingLeaseLoss() { + AtomicInteger cancelled = new AtomicInteger(); + AtomicInteger lostSignals = new AtomicInteger(); + + try (LeaseWatchdog watchdog = new LeaseWatchdog(1, 1, Clock.fixed(NOW, ZoneOffset.UTC))) { + LeaseWatchdog.Registration registration = + watchdog.watch( + new FakeHandle(), + Duration.ofSeconds(10), + Duration.ofSeconds(5), + NOW.plusSeconds(30), + cancelled::incrementAndGet, + state -> lostSignals.incrementAndGet()); + + registration.close(); + registration.runOnceForTest(); + + assertThat(cancelled).hasValue(0); + assertThat(lostSignals).hasValue(0); + assertThat(watchdog.activeRegistrations()).isZero(); + } + } + + private static final class FakeHandle implements LeaseHandle { + + private LeaseState state = LeaseState.ACTIVE; + private LeaseRenewOutcome renewOutcome = new LeaseRenewOutcome.Renewed(Duration.ofSeconds(9)); + + @Override + public String ownerToken() { + return "owner_token_1234567890"; + } + + @Override + public String operationId() { + return "operation_token_12345"; + } + + @Override + public Instant acquiredAt() { + return NOW; + } + + @Override + public Duration remainingValidity() { + return state == LeaseState.ACTIVE ? Duration.ofSeconds(9) : Duration.ZERO; + } + + @Override + public Instant observedServerExpiry() { + return NOW.plusSeconds(10); + } + + @Override + public LeaseState state() { + return state; + } + + @Override + public LeaseRenewOutcome renew(Duration leaseTtl) { + if (renewOutcome instanceof LeaseRenewOutcome.Indeterminate) { + state = LeaseState.UNKNOWN; + } + return renewOutcome; + } + + @Override + public LeaseReleaseOutcome release() { + state = LeaseState.RELEASED; + return new LeaseReleaseOutcome.Released(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java new file mode 100644 index 0000000..4172e1a --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class InitializeNotificationWriterFencesUseCaseTest { + + @Test + void exactReviewedRouteSetIsInitializedAtomicallyInsidePhysicalRootWrite() { + NotificationWriterRouteSet routeSet = routeSet(); + TrackingRootTransactions transactions = new TrackingRootTransactions(); + AtomicInteger operations = new AtomicInteger(); + InitializeNotificationWriterFencesOperation operation = + (command, trustedRoutes, now) -> { + assertThat(transactions.active).isTrue(); + assertThat(trustedRoutes).isSameAs(routeSet); + operations.incrementAndGet(); + return new InitializeNotificationWriterFencesResult( + InitializeNotificationWriterFencesResult.Status.INITIALIZED, + trustedRoutes.canonicalRoutes().routes().size(), + trustedRoutes.digest()); + }; + InitializeNotificationWriterFencesUseCase useCase = + new InitializeNotificationWriterFencesUseCase( + routeSet, + operation, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + InitializeNotificationWriterFencesResult result = + useCase.handle(command(routeSet.canonicalRoutes(), routeSet.digest())); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(operations).hasValue(1); + assertThat(result.status()) + .isEqualTo(InitializeNotificationWriterFencesResult.Status.INITIALIZED); + } + + @Test + void routeOrDigestDriftFailsBeforeMutationAndCommandCannotInitializePartially() { + NotificationWriterRouteSet routeSet = routeSet(); + TrackingRootTransactions transactions = new TrackingRootTransactions(); + AtomicInteger operations = new AtomicInteger(); + InitializeNotificationWriterFencesUseCase useCase = + new InitializeNotificationWriterFencesUseCase( + routeSet, + (command, trustedRoutes, now) -> { + operations.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.systemUTC()); + NotificationCanonicalWriterRouteSet partial = + new NotificationCanonicalWriterRouteSet( + List.of(routeSet.canonicalRoutes().routes().getFirst())); + + assertThatThrownBy(() -> useCase.handle(command(partial, partial.digest()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exact"); + assertThatThrownBy(() -> useCase.handle(command(routeSet.canonicalRoutes(), "0".repeat(64)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("digest"); + assertThat(transactions.rootCalls).isZero(); + assertThat(operations).hasValue(0); + } + + private static InitializeNotificationWriterFencesCommand command( + NotificationCanonicalWriterRouteSet reviewedRoutes, String digest) { + return new InitializeNotificationWriterFencesCommand( + "initialize-operation-42", + reviewedRoutes, + digest, + "operator-42", + new NotificationReasonCode("INITIALIZE_REVIEWED_ROUTES")); + } + + static NotificationWriterRouteSet routeSet() { + NotificationCanonicalWriterRouteSet.RouteRevision email = + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("security-email"), 2, 7); + NotificationCanonicalWriterRouteSet.RouteRevision slack = + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("security-slack"), 1, 3); + NotificationCanonicalWriterRouteSet canonical = + new NotificationCanonicalWriterRouteSet(List.of(email, slack)); + return new NotificationWriterRouteSet( + canonical, + List.of( + new NotificationWriterRouteSet.RouteProfile( + email, + java.util.Optional.of("legacy-email"), + List.of( + new NotificationWriterRouteSet.TransportProfile( + "legacy-http-v1", + NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED, + "evidence-r1", + true))), + new NotificationWriterRouteSet.RouteProfile( + slack, + java.util.Optional.of("legacy-slack"), + List.of( + new NotificationWriterRouteSet.TransportProfile( + "legacy-slack-v1", + NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN, + "evidence-r2", + true))))); + } + + static final class TrackingRootTransactions implements TransactionPort { + + int rootCalls; + boolean active; + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + rootCalls++; + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java new file mode 100644 index 0000000..007330a --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java @@ -0,0 +1,167 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationAdmissionGateUseCaseTest { + + @Test + void readinessProbeRunsOutsideTransactionThenResumeCasRunsInsideShortWrite() { + List trace = new ArrayList<>(); + TrackingTransactions transactions = new TrackingTransactions(trace); + NotificationAdmissionReadinessPort admission = + new NotificationAdmissionReadinessPort() { + @Override + public ParkResult park(ParkRequest request) { + return ParkResult.PARKED; + } + + @Override + public ReadinessProbe probe(ResumeRequest request) { + assertThat(transactions.active).isFalse(); + trace.add("probe"); + return new ReadinessProbe(true, new NotificationReasonCode("READINESS_CONFIRMED")); + } + + @Override + public ResumeResult resume( + ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + assertThat(transactions.active).isTrue(); + assertThat(request.operationToken()).isEqualTo("resume-operation-42"); + assertThat(request.actorReference()).isEqualTo("operator-42"); + assertThat(request.maximumParkedLegs()).isEqualTo(10); + trace.add("resume"); + return new ResumeResult( + ResumeStatus.RESUMED, request.expectedGeneration() + 1, 4, 2, 1, 1, 0, 0); + } + }; + NotificationAdmissionGateUseCase useCase = + new NotificationAdmissionGateUseCase( + admission, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + NotificationAdmissionGateUseCase.Result result = useCase.handle(command()); + + assertThat(trace).containsExactly("probe", "tx-begin", "resume", "tx-end"); + assertThat(result.status()).isEqualTo(NotificationAdmissionGateUseCase.Result.Status.RESUMED); + } + + @Test + void failedProbeDoesNotEnterMutationTransaction() { + List trace = new ArrayList<>(); + TrackingTransactions transactions = new TrackingTransactions(trace); + NotificationAdmissionReadinessPort admission = + new NotificationAdmissionReadinessPort() { + @Override + public ParkResult park(ParkRequest request) { + return ParkResult.PARKED; + } + + @Override + public ReadinessProbe probe(ResumeRequest request) { + trace.add("probe"); + return new ReadinessProbe(false, new NotificationReasonCode("READINESS_FAILED")); + } + + @Override + public ResumeResult resume( + ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + throw new AssertionError("resume must not run"); + } + }; + + NotificationAdmissionGateUseCase.Result result = + new NotificationAdmissionGateUseCase( + admission, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)) + .handle(command()); + + assertThat(trace).containsExactly("probe"); + assertThat(result.status()).isEqualTo(NotificationAdmissionGateUseCase.Result.Status.NOT_READY); + } + + @Test + void resumeOutcomeIsBoundedAndInitialFallbackActivationIsForbidden() { + assertThatThrownBy( + () -> + new NotificationAdmissionReadinessPort.ResumeResult( + NotificationAdmissionReadinessPort.ResumeStatus.RESUMED, 8, 1, 0, 0, 0, 0, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fallback"); + assertThatThrownBy( + () -> + new NotificationAdmissionGateCommand( + "resume-operation-42", + new NotificationRouteId("security-email"), + 2, + NotificationFaultScope.PROVIDER_BINDING, + "provider-binding-scope-42", + 7, + 101, + "operator-42", + new NotificationReasonCode("OPERATOR_RESUME"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1..100"); + } + + private static NotificationAdmissionGateCommand command() { + return new NotificationAdmissionGateCommand( + "resume-operation-42", + new NotificationRouteId("security-email"), + 2, + NotificationFaultScope.PROVIDER_BINDING, + "provider-binding-scope-42", + 7, + 10, + "operator-42", + new NotificationReasonCode("OPERATOR_RESUME")); + } + + private static final class TrackingTransactions implements TransactionPort { + + private final List trace; + private boolean active; + + private TrackingTransactions(List trace) { + this.trace = trace; + } + + @Override + public T inWrite(Supplier action) { + trace.add("tx-begin"); + active = true; + try { + return action.get(); + } finally { + active = false; + trace.add("tx-end"); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java new file mode 100644 index 0000000..a06f909 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class NotificationCanonicalWriterFenceGuardTest { + + @Test + void exactCanonicalOwnerAndGenerationAreRequiredBeforeIntentAppend() { + NotificationCanonicalWriterRouteSet.RouteRevision route = route(); + NotificationCanonicalWriterFencePort port = + request -> + new NotificationCanonicalWriterFencePort.FenceSnapshot( + request.route(), NotificationWriterOwnership.CANONICAL, 8); + NotificationCanonicalWriterFenceGuard guard = + new NotificationCanonicalWriterFenceGuard( + port, new NotificationCanonicalWriterRouteSet(List.of(route))); + AtomicInteger appends = new AtomicInteger(); + + guard.assertCanonical(route, 8); + appends.incrementAndGet(); + + assertThat(appends).hasValue(1); + assertThatThrownBy(() -> guard.assertCanonical(route, 7)) + .isInstanceOf(NotificationApplicationException.class) + .hasMessageContaining("STALE_CANONICAL_WRITER_GENERATION"); + } + + @Test + void legacyOwnerOrUnknownRouteFailsClosedBeforeAppend() { + NotificationCanonicalWriterRouteSet.RouteRevision route = route(); + NotificationCanonicalWriterFenceGuard legacyGuard = + new NotificationCanonicalWriterFenceGuard( + request -> + new NotificationCanonicalWriterFencePort.FenceSnapshot( + request.route(), NotificationWriterOwnership.LEGACY, 7), + new NotificationCanonicalWriterRouteSet(List.of(route))); + AtomicInteger appends = new AtomicInteger(); + + assertThatThrownBy( + () -> { + legacyGuard.assertCanonical(route, 7); + appends.incrementAndGet(); + }) + .isInstanceOf(NotificationApplicationException.class) + .hasMessageContaining("CANONICAL_WRITER_NOT_OWNER"); + assertThatThrownBy( + () -> + legacyGuard.assertCanonical( + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("unknown-route"), 1, 0), + 7)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(appends).hasValue(0); + } + + private static NotificationCanonicalWriterRouteSet.RouteRevision route() { + return new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("security-email"), 2, 7); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java new file mode 100644 index 0000000..2952133 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java @@ -0,0 +1,134 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class NotificationCapabilityCompatibilityValidatorTest { + + @Test + void pureValidatorAcceptsExactDurableReceiptCapableApplicationContract() { + NotificationKindPolicy policy = policy(); + NotificationCapabilityCompatibilityValidator validator = + new NotificationCapabilityCompatibilityValidator(); + + NotificationCapabilityCompatibilityValidator.Compatibility result = + validator.validate(policy, provider(true), store(policy), Optional.of(ingress()), true); + + assertThat(result.compatible()).isTrue(); + assertThat(result.reasonCodes()).isEmpty(); + } + + @Test + void modeAdmissionReceiptAndFrozenRevisionMismatchesFailClosedWithoutAdapterTypes() { + NotificationKindPolicy policy = policy(); + NotificationCapabilityCompatibilityValidator validator = + new NotificationCapabilityCompatibilityValidator(); + + NotificationCapabilityCompatibilityValidator.Compatibility result = + validator.validate( + policy, + provider(false), + new NotificationStoreCapabilityDescriptor( + false, + false, + true, + 100, + Set.of(policy.policyRevision() - 1), + Set.of(new NotificationTemplateRef("security-alert", 99))), + Optional.empty(), + true); + + assertThat(result.compatible()).isFalse(); + assertThat(result.reasonCodes()) + .contains( + new NotificationReasonCode("PROVIDER_RECEIPT_UNSUPPORTED"), + new NotificationReasonCode("DURABLE_STORE_UNAVAILABLE"), + new NotificationReasonCode("POLICY_REVISION_UNAVAILABLE"), + new NotificationReasonCode("TEMPLATE_REVISION_UNAVAILABLE"), + new NotificationReasonCode("RECEIPT_INGRESS_UNAVAILABLE")); + assertThat( + java.util.Arrays.stream( + NotificationCapabilityCompatibilityValidator.class.getDeclaredMethods()) + .flatMap( + method -> + java.util.Arrays.stream(method.getGenericParameterTypes()) + .map(java.lang.reflect.Type::getTypeName))) + .noneMatch( + name -> + name.contains("adapter.") + || name.contains("Settings") + || name.contains("CompiledNotificationBinding")); + } + + @Test + void providerChannelOrCodeOwnedModeMismatchIsRejected() { + NotificationKindPolicy policy = policy(); + NotificationProviderCapabilityDescriptor wrongChannel = + new NotificationProviderCapabilityDescriptor( + "provider-capability-42", + NotificationChannel.SLACK, + Set.of(NotificationMode.DURABLE_ASYNC), + true, + true, + true, + 16, + 1_000_000); + + assertThatThrownBy( + () -> + new NotificationCapabilityCompatibilityValidator() + .requireCompatible( + policy, wrongChannel, store(policy), Optional.of(ingress()), true)) + .isInstanceOf(NotificationApplicationException.class) + .hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE"); + } + + private static NotificationKindPolicy policy() { + return new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-email"), + new NotificationTemplateRef("security-alert", 3), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 7, + 1, + 3, + 0, + 1, + 4, + Duration.ofHours(2)); + } + + private static NotificationProviderCapabilityDescriptor provider(boolean receipts) { + return new NotificationProviderCapabilityDescriptor( + "provider-capability-42", + NotificationChannel.EMAIL, + Set.of(NotificationMode.DURABLE_ASYNC), + receipts, + true, + true, + 16, + 1_000_000); + } + + private static NotificationStoreCapabilityDescriptor store(NotificationKindPolicy policy) { + return new NotificationStoreCapabilityDescriptor( + true, true, true, 100, Set.of(policy.policyRevision()), Set.of(policy.templateRef())); + } + + private static NotificationReceiptIngressCapabilityDescriptor ingress() { + return new NotificationReceiptIngressCapabilityDescriptor( + NotificationChannel.EMAIL, + true, + true, + java.util.EnumSet.allOf(NotificationReceiptFact.Type.class)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java new file mode 100644 index 0000000..cb1f839 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java @@ -0,0 +1,395 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationDispatchUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void claimAuthorizeProviderAndFinalizeUseShortTransactionsAroundProviderIo() { + List trace = new ArrayList<>(); + TrackingTransactionPort transactions = new TrackingTransactionPort(trace); + RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied()); + NotificationProviderAttemptPort provider = + attempt -> { + assertThat(transactions.active()).isFalse(); + trace.add("provider"); + return accepted(); + }; + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + provider, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(10)); + + assertThat(trace) + .containsExactly( + "tx-begin", + "claim", + "tx-end", + "tx-begin", + "authorize", + "tx-end", + "provider", + "tx-begin", + "finalize", + "tx-end"); + assertThat(store.authorizedAttempt.claimToken()) + .isNotEqualTo(store.authorizedAttempt.executionToken()); + assertThat(result).isEqualTo(new NotificationDispatchResult(1, 1, 1, 1, 0, 0, 0)); + } + + @Test + void staleClaimNeverCallsProviderOrFinalize() { + List trace = new ArrayList<>(); + TrackingTransactionPort transactions = new TrackingTransactionPort(trace); + RecordingStore store = + new RecordingStore( + trace, + sampleClaim(), + new NotificationDeliveryStorePort.StaleClaim( + sampleClaim().deliveryId(), new NotificationReasonCode("STALE_CLAIM_TOKEN"))); + AtomicBoolean providerCalled = new AtomicBoolean(); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> { + providerCalled.set(true); + return accepted(); + }, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(1)); + + assertThat(providerCalled).isFalse(); + assertThat(store.finalization).isNull(); + assertThat(result.staleClaimCount()).isEqualTo(1); + } + + @Test + void fallbackIsEligibleOnlyForDefinitelyNotAppliedAndIndeterminateIsTerminal() { + RecordingStore definiteStore = + dispatchWithOutcome( + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.TERMINAL, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("INVALID_RECIPIENT"), + Optional.empty(), + "correlation-42", + Optional.empty())); + RecordingStore indeterminateStore = + dispatchWithOutcome( + new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("RESPONSE_LOST"), + Optional.empty(), + "correlation-42", + Optional.empty())); + + assertThat(definiteStore.finalization.fallbackEligible()).isTrue(); + assertThat(definiteStore.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE); + assertThat(indeterminateStore.finalization.fallbackEligible()).isFalse(); + assertThat(indeterminateStore.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.TERMINAL_INDETERMINATE); + } + + @Test + void parkBindingCasRunsInFinalizeTransactionAndDoesNotActivateInitialFallback() { + List trace = new ArrayList<>(); + TrackingTransactionPort transactions = new TrackingTransactionPort(trace); + RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied()); + AtomicBoolean parkInsideTransaction = new AtomicBoolean(); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_AUTH_REJECTED"), + Optional.empty(), + "correlation-42", + Optional.empty()), + request -> { + parkInsideTransaction.set(transactions.active()); + assertThat(request.expectedGeneration()).isEqualTo(7); + return NotificationAdmissionReadinessPort.ParkResult.PARKED; + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(1)); + + assertThat(parkInsideTransaction).isTrue(); + assertThat(store.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.PARKED_BINDING); + assertThat(store.finalization.fallbackEligible()).isFalse(); + assertThat(result.parkedCount()).isEqualTo(1); + } + + @Test + void exactLateResultIsCountedWithoutBlindProviderRetry() { + RecordingStore store = + new RecordingStore(new ArrayList<>(), sampleClaim(), finalizationLateExact()); + AtomicBoolean providerCalled = new AtomicBoolean(); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> { + providerCalled.set(true); + return accepted(); + }, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + new TrackingTransactionPort(new ArrayList<>()), + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(1)); + + assertThat(providerCalled).isTrue(); + assertThat(result.providerCallCount()).isEqualTo(1); + assertThat(result.finalizedCount()).isEqualTo(1); + } + + @Test + void staleAttemptExecutionTokenCannotFinalizeOrTriggerAnotherProviderCall() { + RecordingStore store = + new RecordingStore( + new ArrayList<>(), + sampleClaim(), + NotificationDeliveryStorePort.FinalizationResult.STALE_EXECUTION_TOKEN); + AtomicBoolean providerCalled = new AtomicBoolean(); + NotificationDispatchResult result = + new NotificationDispatchUseCase( + store, + attempt -> { + providerCalled.set(true); + return accepted(); + }, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + new TrackingTransactionPort(new ArrayList<>()), + Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(new NotificationDispatchCommand(1)); + + assertThat(providerCalled).isTrue(); + assertThat(result.providerCallCount()).isEqualTo(1); + assertThat(result.finalizedCount()).isZero(); + } + + private static RecordingStore dispatchWithOutcome(ProviderAttemptOutcome outcome) { + RecordingStore store = + new RecordingStore(new ArrayList<>(), sampleClaim(), finalizationApplied()); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> outcome, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + new TrackingTransactionPort(new ArrayList<>()), + Clock.fixed(NOW, ZoneOffset.UTC)); + useCase.handle(new NotificationDispatchCommand(1)); + return store; + } + + private static NotificationDeliveryStorePort.ClaimedDelivery sampleClaim() { + return new NotificationDeliveryStorePort.ClaimedDelivery( + new NotificationDeliveryId("delivery-42"), samplePlan(), 0, "claim-token-42", 3, 7); + } + + private static NotificationFrozenPlan samplePlan() { + NotificationKindPolicy policy = + new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-email"), + new NotificationTemplateRef("security-alert", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 2, + 1, + 2, + 0, + 1, + 3, + java.time.Duration.ofHours(1)); + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + java.util.Locale.ENGLISH, + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "security-alert", + "source-operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + NOW, + NOW.plusSeconds(600)); + return NotificationFrozenPlan.from(draft, java.util.Locale.ENGLISH); + } + + private static ProviderAttemptOutcome accepted() { + return new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + "correlation-42", + Optional.of("provider-message-42")); + } + + private static NotificationDeliveryStorePort.FinalizationResult finalizationApplied() { + return NotificationDeliveryStorePort.FinalizationResult.APPLIED; + } + + private static NotificationDeliveryStorePort.FinalizationResult finalizationLateExact() { + return NotificationDeliveryStorePort.FinalizationResult.LATE_EXACT_APPLIED; + } + + private static final class RecordingStore implements NotificationDeliveryStorePort { + + private final List trace; + private final ClaimedDelivery claim; + private final AttemptAuthorization authorization; + private final FinalizationResult finalizationResult; + private AuthorizedAttempt authorizedAttempt; + private AttemptFinalization finalization; + + private RecordingStore( + List trace, ClaimedDelivery claim, FinalizationResult finalizationResult) { + this.trace = trace; + this.claim = claim; + this.authorization = + new Authorized( + new AuthorizedAttempt( + claim.deliveryId(), + new NotificationAttemptId("attempt-42"), + claim.plan(), + claim.targetOrdinal(), + claim.claimToken(), + "execution-token-42", + claim.rowVersion(), + claim.admissionGeneration(), + "provider-binding-scope-42", + NOW.plusSeconds(30))); + this.finalizationResult = finalizationResult; + } + + private RecordingStore( + List trace, ClaimedDelivery claim, AttemptAuthorization authorization) { + this.trace = trace; + this.claim = claim; + this.authorization = authorization; + this.finalizationResult = FinalizationResult.APPLIED; + } + + @Override + public List claimEligible(int maximumClaims, Instant now) { + trace.add("claim"); + return List.of(claim); + } + + @Override + public AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now) { + trace.add("authorize"); + if (authorization instanceof Authorized authorized) { + authorizedAttempt = authorized.attempt(); + } + return authorization; + } + + @Override + public FinalizationResult finalizeAttempt( + AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now) { + trace.add("finalize"); + this.finalization = finalization; + return finalizationResult; + } + + @Override + public List claimForReconciliation(int maximumClaims, Instant now) { + return List.of(); + } + + @Override + public ReconciliationFinalizationResult finalizeReconciliation( + ReconciliationClaim claim, + NotificationReconciliationPort.ReconciliationOutcome outcome, + Instant now) { + throw new UnsupportedOperationException(); + } + + @Override + public int attachOrphanReceipts(int maximumAttachments, Instant now) { + return 0; + } + } + + private static final class TrackingTransactionPort implements TransactionPort { + + private final List trace; + private boolean active; + + private TrackingTransactionPort(List trace) { + this.trace = trace; + } + + @Override + public T inWrite(Supplier action) { + trace.add("tx-begin"); + active = true; + try { + return action.get(); + } finally { + active = false; + trace.add("tx-end"); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + + private boolean active() { + return active; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java new file mode 100644 index 0000000..264d1a7 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class NotificationKindPolicyTest { + + @Test + void modeAndAdmissionClassAreCodeOwnedAndRuntimeConfigCanOnlyAssertExactValues() { + NotificationKindPolicy policy = + policy( + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE); + + assertThat( + policy.assertRuntimeExpectation( + NotificationMode.DURABLE_ASYNC, NotificationAdmissionClass.SECURITY_CRITICAL)) + .isSameAs(policy); + assertThatThrownBy( + () -> + policy.assertRuntimeExpectation( + NotificationMode.BEST_EFFORT_INLINE, + NotificationAdmissionClass.SECURITY_CRITICAL)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mode"); + assertThatThrownBy( + () -> + policy.assertRuntimeExpectation( + NotificationMode.DURABLE_ASYNC, NotificationAdmissionClass.TRANSACTIONAL)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("admission"); + } + + @Test + void configCannotStrengthenInlinePolicyToDurable() { + NotificationKindPolicy policy = + policy( + NotificationMode.BEST_EFFORT_INLINE, + NotificationAdmissionClass.BULK_LOW_VALUE, + NotificationRouteStrategy.FAN_OUT_ALL); + + assertThatThrownBy( + () -> + policy.assertRuntimeExpectation( + NotificationMode.DURABLE_ASYNC, NotificationAdmissionClass.BULK_LOW_VALUE)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void criticalKindCannotBindBestEffortInline() { + assertThatThrownBy( + () -> + policy( + NotificationMode.BEST_EFFORT_INLINE, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SECURITY_CRITICAL"); + } + + @Test + void amplificationBudgetsAreClosedAndInternallyConsistent() { + assertThatThrownBy( + () -> + new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.SLACK, + new NotificationRouteId("slack-security"), + new NotificationTemplateRef("security-alert", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.FAN_OUT_ALL, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 1, + 4, + 3, + 0, + 1, + 3, + Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider calls"); + } + + private static NotificationKindPolicy policy( + NotificationMode mode, + NotificationAdmissionClass admissionClass, + NotificationRouteStrategy strategy) { + return new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.SLACK, + new NotificationRouteId("slack-security"), + new NotificationTemplateRef("security-alert", 1), + mode, + admissionClass, + strategy, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 3, + strategy == NotificationRouteStrategy.SINGLE ? 1 : 2, + mode == NotificationMode.BEST_EFFORT_INLINE ? 1 : 2, + strategy == NotificationRouteStrategy.ORDERED_FALLBACK ? 1 : 0, + mode == NotificationMode.BEST_EFFORT_INLINE ? 0 : 1, + 6, + Duration.ofHours(2)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java new file mode 100644 index 0000000..e263bfa --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class NotificationLegacyWriterPermitUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void acquireRootCommitsDbTimeWireDeadlineAndExpiryBeforeCallerCanStartNetwork() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + NotificationWriterCutoverPort port = new RecordingCutoverPort(transactions); + NotificationLegacyWriterPermitUseCase useCase = + new NotificationLegacyWriterPermitUseCase( + routeSet, port, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationLegacyWriterPermitResult result = + useCase.handle( + NotificationLegacyWriterPermitCommand.acquire( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + "legacy-http-v1", + "permit-42", + "node-42", + "permit-operation-42", + "operator-42", + new NotificationReasonCode("LEGACY_SEND"), + Duration.ofSeconds(5))); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.status()).isEqualTo(NotificationLegacyWriterPermitResult.Status.ACQUIRED); + assertThat(result.acquiredAt()).contains(NOW); + assertThat(result.wireDeadline()).contains(NOW.plusSeconds(5)); + assertThat(result.expiresAt()).contains(NOW.plusSeconds(10)); + assertThat(result.wireDeadline().orElseThrow()) + .isBeforeOrEqualTo(result.expiresAt().orElseThrow()); + } + + @Test + void unknownProfileAndUnboundedWireBudgetFailBeforePortMutation() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + AtomicInteger calls = new AtomicInteger(); + NotificationLegacyWriterPermitUseCase useCase = + new NotificationLegacyWriterPermitUseCase( + routeSet, + new NotificationWriterCutoverPort() { + @Override + public NotificationLegacyWriterPermitResult acquireLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + calls.incrementAndGet(); + throw new AssertionError("port must not run"); + } + + @Override + public NotificationLegacyWriterPermitResult releaseLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + calls.incrementAndGet(); + throw new AssertionError("port must not run"); + } + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + useCase.handle( + NotificationLegacyWriterPermitCommand.acquire( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + "unknown-profile", + "permit-42", + "node-42", + "permit-operation-42", + "operator-42", + new NotificationReasonCode("LEGACY_SEND"), + Duration.ofSeconds(5)))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + NotificationLegacyWriterPermitCommand.acquire( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + "legacy-http-v1", + "permit-42", + "node-42", + "permit-operation-42", + "operator-42", + new NotificationReasonCode("LEGACY_SEND"), + Duration.ofMinutes(2))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(calls).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } + + private static final class RecordingCutoverPort implements NotificationWriterCutoverPort { + + private final InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions + transactions; + + private RecordingCutoverPort( + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions) { + this.transactions = transactions; + } + + @Override + public NotificationLegacyWriterPermitResult acquireLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + assertThat(transactions.active).isTrue(); + return new NotificationLegacyWriterPermitResult( + NotificationLegacyWriterPermitResult.Status.ACQUIRED, + command.permitToken(), + Optional.of(requestedAt), + Optional.of(requestedAt.plus(command.wireBudget().orElseThrow())), + Optional.of(requestedAt.plusSeconds(10))); + } + + @Override + public NotificationLegacyWriterPermitResult releaseLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + return new NotificationLegacyWriterPermitResult( + NotificationLegacyWriterPermitResult.Status.RELEASED, + command.permitToken(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java new file mode 100644 index 0000000..2c81b48 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationMaintenanceUseCaseTest { + + @Test + void maintenanceIsBoundedAndRunsInsideOneShortWriteTransaction() { + TrackingTransactions transactions = new TrackingTransactions(); + NotificationMaintenanceStorePort store = + (command, now) -> { + assertThat(transactions.active).isTrue(); + return new NotificationMaintenanceStorePort.MutationResult(3, 2, 1); + }; + NotificationMaintenanceUseCase useCase = + new NotificationMaintenanceUseCase( + store, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + NotificationMaintenanceResult result = + useCase.handle(new NotificationMaintenanceCommand(40, 30, 30)); + + assertThat(transactions.writeCalls).isEqualTo(1); + assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1)); + assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static final class TrackingTransactions implements TransactionPort { + + private boolean active; + private int writeCalls; + + @Override + public T inWrite(Supplier action) { + writeCalls++; + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java new file mode 100644 index 0000000..183ad9d --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java @@ -0,0 +1,111 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.time.Instant; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationOperationsSnapshotUseCaseTest { + + @Test + void concreteQueryUseCaseLoadsOnlyBoundedSnapshotInsideReadTransaction() { + TrackingTransactions transactions = new TrackingTransactions(); + NotificationOperationsSnapshot expected = + new NotificationOperationsSnapshot( + Instant.parse("2026-07-28T00:00:00Z"), + 12, + 2, + 1, + 0, + List.of( + new NotificationOperationsSnapshot.RouteWriterStatus( + new NotificationRouteId("security-email"), + 2, + NotificationWriterOwnership.CANONICAL, + 8, + false))); + NotificationOperationsSnapshotPort port = + query -> { + assertThat(transactions.readActive).isTrue(); + assertThat(query.maximumRoutes()).isEqualTo(100); + return expected; + }; + NotificationOperationsSnapshotUseCase useCase = + new NotificationOperationsSnapshotUseCase(port, transactions); + + NotificationOperationsSnapshot result = + useCase.handle(new NotificationOperationsSnapshotQuery(100)); + + assertThat(result).isEqualTo(expected); + assertThat(transactions.readCalls).isEqualTo(1); + assertThat(useCase).isInstanceOf(QueryUseCase.class); + UseCaseCapability capability = + NotificationOperationsSnapshotUseCase.class.getAnnotation(UseCaseCapability.class); + assertThat(capability.transactionMode()).isEqualTo(TransactionMode.READ_ONLY); + assertThat(capability.repositoryAccess()).isEqualTo(RepositoryAccess.READ_REPOSITORY); + assertThat(capability.idempotency()).isEqualTo(Idempotency.IDEMPOTENT); + assertThat(capability.externalOutboundAllowed()).isFalse(); + assertThat(capability.sensitiveRead()).isFalse(); + assertThat(capability.crossTenantAdmin()).isTrue(); + } + + @Test + void snapshotDefensivelyCopiesBoundedNonSensitiveRouteValues() { + java.util.ArrayList mutable = + new java.util.ArrayList<>( + List.of( + new NotificationOperationsSnapshot.RouteWriterStatus( + new NotificationRouteId("security-email"), + 2, + NotificationWriterOwnership.LEGACY, + 7, + true))); + NotificationOperationsSnapshot snapshot = + new NotificationOperationsSnapshot( + Instant.parse("2026-07-28T00:00:00Z"), 0, 0, 0, 0, mutable); + mutable.clear(); + + assertThat(snapshot.writerRoutes()).hasSize(1); + assertThat(snapshot.toString()).doesNotContain("recipient", "payload", "credential"); + } + + private static final class TrackingTransactions implements TransactionPort { + + private boolean readActive; + private int readCalls; + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + readCalls++; + readActive = true; + try { + return action.get(); + } finally { + readActive = false; + } + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java new file mode 100644 index 0000000..6be96c2 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java @@ -0,0 +1,155 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationPlanningBoundaryTest { + + @Test + void featureFactoryAndCodePolicyProduceDraftThenPlannerReturnsApplicationFrozenPlan() { + NotificationKindPolicy policy = securityAlertPolicy(); + SecurityAlertNotificationRequestFactory factory = + new SecurityAlertNotificationRequestFactory(policy); + NotificationIntentDraft draft = + factory.create( + "intent-42", + "recipient-ref-42", + "source-operation-42", + Instant.parse("2026-07-28T00:00:00Z")); + NotificationPlanPort planner = + requested -> + new NotificationPlanningResult.Planned( + NotificationFrozenPlan.from(requested, Locale.ENGLISH)); + + NotificationPlanningResult result = planner.plan(draft); + + assertThat(result).isInstanceOf(NotificationPlanningResult.Planned.class); + NotificationFrozenPlan plan = ((NotificationPlanningResult.Planned) result).plan(); + assertThat(plan.policy()).isSameAs(policy); + assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC); + assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH); + } + + @Test + void appendAndInlinePortsConsumeTheSameFrozenApplicationPlan() { + NotificationIntentDraft draft = + new SecurityAlertNotificationRequestFactory(securityAlertPolicy()) + .create( + "intent-42", + "recipient-ref-42", + "source-operation-42", + Instant.parse("2026-07-28T00:00:00Z")); + NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.ENGLISH); + AtomicReference appended = new AtomicReference<>(); + AtomicReference attempted = new AtomicReference<>(); + NotificationIntentAppendPort appendPort = + frozenPlan -> { + appended.set(frozenPlan); + return new NotificationAppendResult.Appended(frozenPlan.intentId()); + }; + InlineNotificationAttemptPort inlinePort = + frozenPlan -> { + attempted.set(frozenPlan); + return new NotificationRequestResult.InlineCompleted( + frozenPlan.intentId(), + java.util.List.of( + new TargetAttemptOutcome( + 0, + new NotificationDeliveryId("delivery-42"), + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + "correlation-42", + Optional.of("provider-message-42"))))); + }; + + assertThat(appendPort.append(plan)) + .isEqualTo(new NotificationAppendResult.Appended(plan.intentId())); + assertThat(inlinePort.attempt(plan).intentId()).isEqualTo(plan.intentId()); + assertThat(appended).hasValue(plan); + assertThat(attempted).hasValue(plan); + } + + @Test + void planningFailuresStayBoundedAndProviderNeutral() { + NotificationPlanPort planner = + draft -> + new NotificationPlanningResult.Rejected( + new NotificationReasonCode("ROUTE_NOT_QUALIFIED")); + + NotificationPlanningResult result = + planner.plan( + new SecurityAlertNotificationRequestFactory(securityAlertPolicy()) + .create( + "intent-42", + "recipient-ref-42", + "source-operation-42", + Instant.parse("2026-07-28T00:00:00Z"))); + + assertThat(result) + .isEqualTo( + new NotificationPlanningResult.Rejected( + new NotificationReasonCode("ROUTE_NOT_QUALIFIED"))); + assertThat(result.toString()) + .doesNotContain("software.amazon", "slack.api", "webhook", "credential"); + } + + private static NotificationKindPolicy securityAlertPolicy() { + return new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-alert-email"), + new NotificationTemplateRef("security-alert", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 1, + 1, + 2, + 0, + 1, + 3, + Duration.ofHours(2)); + } + + /** + * Feature-specific factory example intentionally kept in test fixtures, not generic production. + */ + private static final class SecurityAlertNotificationRequestFactory { + + private final NotificationKindPolicy policy; + + private SecurityAlertNotificationRequestFactory(NotificationKindPolicy policy) { + this.policy = policy; + } + + private NotificationIntentDraft create( + String intentId, String recipientReference, String sourceOperationId, Instant now) { + return new NotificationIntentDraft( + new NotificationIntentId(intentId), + policy, + Locale.forLanguageTag("ko-KR"), + new EmailRecipientReference(recipientReference), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("redacted-value"))), + "security-alert", + sourceOperationId, + Optional.empty(), + "correlation-42", + Optional.empty(), + now, + now.plus(Duration.ofMinutes(30))); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java new file mode 100644 index 0000000..bdd6121 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class NotificationPortBoundaryTest { + + @Test + void appendInlineAndPlanningPortsAreNarrowAndOwnNoTransactionMechanism() { + assertThat(NotificationPlanPort.class.getDeclaredMethods()).hasSize(1); + assertThat(NotificationIntentAppendPort.class.getDeclaredMethods()).hasSize(1); + assertThat(InlineNotificationAttemptPort.class.getDeclaredMethods()).hasSize(1); + + Method append = NotificationIntentAppendPort.class.getDeclaredMethods()[0]; + assertThat(append.getName()).isEqualTo("append"); + assertThat(append.getParameterTypes()).containsExactly(NotificationFrozenPlan.class); + assertThat(append.getReturnType()).isEqualTo(NotificationAppendResult.class); + assertThat(Arrays.stream(append.getParameterTypes()).map(Class::getName)) + .noneMatch(name -> name.contains("Transaction") || name.contains("Entity")); + } + + @Test + void publicPortSignaturesContainOnlyApplicationAndJdkTypes() { + Set typeNames = + java.util.stream.Stream.of( + NotificationPlanPort.class, + NotificationIntentAppendPort.class, + InlineNotificationAttemptPort.class) + .flatMap(type -> Arrays.stream(type.getDeclaredMethods())) + .flatMap( + method -> + java.util.stream.Stream.concat( + java.util.stream.Stream.of(method.getGenericReturnType().getTypeName()), + Arrays.stream(method.getGenericParameterTypes()) + .map(java.lang.reflect.Type::getTypeName))) + .collect(Collectors.toSet()); + + assertThat(typeNames) + .allMatch( + name -> + name.startsWith("dev.caskeleton.application.notification.") + || name.startsWith("java.")); + assertThat(typeNames.stream().map(name -> name.toLowerCase(Locale.ROOT))) + .noneMatch( + name -> + name.contains("slack.api") + || name.contains("software.amazon") + || name.contains("spring") + || name.contains("jakarta.persistence") + || name.contains("entity") + || name.contains("dto") + || name.contains("compilednotificationbinding") + || name.contains("providerruntimeprofile")); + } + + @Test + void noGiantNotificationPortCombinesPlanningAppendAttemptStoreAndReceipt() { + Set methodNames = + java.util.stream.Stream.of( + NotificationPlanPort.class, + NotificationIntentAppendPort.class, + InlineNotificationAttemptPort.class) + .flatMap(type -> Arrays.stream(type.getDeclaredMethods())) + .map(Method::getName) + .collect(Collectors.toSet()); + + assertThat(methodNames).containsExactlyInAnyOrder("plan", "append", "attempt"); + assertThat( + java.util.stream.Stream.of( + NotificationPlanPort.class, + NotificationIntentAppendPort.class, + InlineNotificationAttemptPort.class) + .map(Class::getSimpleName)) + .noneMatch(name -> name.equals("NotificationStorePort") || name.equals("NotificationPort")); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java new file mode 100644 index 0000000..2584ee2 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java @@ -0,0 +1,231 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationReceiptReducerTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void receiptFactOrderDoesNotChangeOrthogonalProjectionAndAcceptanceIsNeverErased() { + List facts = + List.of( + fact(NotificationReceiptFact.Type.SEND, NotificationReceiptFact.BounceClass.NONE, 1), + fact( + NotificationReceiptFact.Type.DELIVERY, NotificationReceiptFact.BounceClass.NONE, 2), + fact(NotificationReceiptFact.Type.BOUNCE, NotificationReceiptFact.BounceClass.HARD, 3), + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 4), + fact( + NotificationReceiptFact.Type.DELIVERY_DELAY, + NotificationReceiptFact.BounceClass.NONE, + 5)); + NotificationReceiptProjection canonical = NotificationReceiptProjection.reduce(facts); + List reversed = new ArrayList<>(facts); + Collections.reverse(reversed); + List rotated = new ArrayList<>(facts); + Collections.rotate(rotated, 2); + + assertThat(NotificationReceiptProjection.reduce(reversed)).isEqualTo(canonical); + assertThat(NotificationReceiptProjection.reduce(rotated)).isEqualTo(canonical); + assertThat(canonical.submissionAccepted()).isTrue(); + assertThat(canonical.delivered()).isTrue(); + assertThat(canonical.hardBounced()).isTrue(); + assertThat(canonical.complained()).isTrue(); + assertThat(canonical.deliveryDelayed()).isTrue(); + assertThat(canonical.latestFactAt()).isEqualTo(NOW.plusSeconds(5)); + } + + @Test + void onlyHardBounceAndComplaintEmitTechnicalSuppression() { + assertThat( + apply( + fact( + NotificationReceiptFact.Type.BOUNCE, + NotificationReceiptFact.BounceClass.HARD, + 1)) + .suppressed) + .isEqualTo(1); + assertThat( + apply( + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 1)) + .suppressed) + .isEqualTo(1); + assertThat( + apply( + fact( + NotificationReceiptFact.Type.BOUNCE, + NotificationReceiptFact.BounceClass.SOFT, + 1)) + .suppressed) + .isZero(); + assertThat( + apply( + fact( + NotificationReceiptFact.Type.DELIVERY_DELAY, + NotificationReceiptFact.BounceClass.NONE, + 1)) + .suppressed) + .isZero(); + } + + @Test + void applyUsesPhysicalRootWriteAndDuplicateDoesNotRepeatSuppression() { + TrackingRootTransactionPort transactions = new TrackingRootTransactionPort(); + RecordingReceiptStore store = + new RecordingReceiptStore( + new NotificationReceiptStorePort.Duplicate( + NotificationReceiptProjection.reduce( + List.of( + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 1))))); + RecordingSuppressionPort suppression = new RecordingSuppressionPort(transactions); + ApplyNotificationReceiptUseCase useCase = + new ApplyNotificationReceiptUseCase( + store, suppression, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + ApplyNotificationReceiptResult result = + useCase.handle( + command( + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 1))); + + assertThat(transactions.rootWriteCalls).isEqualTo(1); + assertThat(store.savedProjection).isNull(); + assertThat(suppression.suppressed).isZero(); + assertThat(result.status()).isEqualTo(ApplyNotificationReceiptResult.Status.DUPLICATE); + } + + private static ReceiptFixture apply(NotificationReceiptFact fact) { + TrackingRootTransactionPort transactions = new TrackingRootTransactionPort(); + NotificationReceiptProjection projection = NotificationReceiptProjection.reduce(List.of(fact)); + RecordingReceiptStore store = + new RecordingReceiptStore( + new NotificationReceiptStorePort.Appended( + new NotificationReceiptStorePort.ReceiptAggregate( + new NotificationDeliveryId("delivery-42"), + new EmailRecipientReference("recipient-ref-42"), + List.of(fact)))); + RecordingSuppressionPort suppression = new RecordingSuppressionPort(transactions); + ApplyNotificationReceiptResult result = + new ApplyNotificationReceiptUseCase( + store, suppression, transactions, Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(command(fact)); + assertThat(result.projection()).isEqualTo(projection); + assertThat(store.savedProjection).isEqualTo(projection); + return new ReceiptFixture(suppression.suppressed); + } + + private static ApplyNotificationReceiptCommand command(NotificationReceiptFact fact) { + return new ApplyNotificationReceiptCommand( + new NormalizedNotificationReceiptCommand( + new NotificationReceiptEventId("receipt-42"), + new NotificationDeliveryId("delivery-42"), + fact)); + } + + private static NotificationReceiptFact fact( + NotificationReceiptFact.Type type, + NotificationReceiptFact.BounceClass bounceClass, + long second) { + return new NotificationReceiptFact( + type, + bounceClass, + new NotificationReasonCode( + type == NotificationReceiptFact.Type.BOUNCE + ? "RECIPIENT_BOUNCE" + : "PROVIDER_" + type.name()), + NOW.plusSeconds(second)); + } + + private record ReceiptFixture(int suppressed) {} + + private static final class RecordingReceiptStore implements NotificationReceiptStorePort { + + private final AppendResult appendResult; + private NotificationReceiptProjection savedProjection; + + private RecordingReceiptStore(AppendResult appendResult) { + this.appendResult = appendResult; + } + + @Override + public AppendResult appendIfAbsent(NormalizedNotificationReceiptCommand command) { + return appendResult; + } + + @Override + public void saveProjection( + NotificationDeliveryId deliveryId, NotificationReceiptProjection projection) { + savedProjection = projection; + } + } + + private static final class RecordingSuppressionPort + implements NotificationTechnicalSuppressionPort { + + private final TrackingRootTransactionPort transactions; + private int suppressed; + + private RecordingSuppressionPort(TrackingRootTransactionPort transactions) { + this.transactions = transactions; + } + + @Override + public void suppress(SuppressionMutation mutation) { + assertThat(transactions.active).isTrue(); + suppressed++; + } + } + + private static final class TrackingRootTransactionPort implements TransactionPort { + + private int rootWriteCalls; + private boolean active; + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + rootWriteCalls++; + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java new file mode 100644 index 0000000..963daa6 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class NotificationRequestResultTest { + + @Test + void providerOutcomeKeepsSubmissionRetryAndFaultAxesOrthogonal() { + Instant retryAt = Instant.parse("2026-07-28T00:01:00Z"); + ProviderAttemptOutcome outcome = + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.RETRY_AT, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_THROTTLED"), + Optional.of(retryAt), + "attempt-correlation-42", + Optional.empty()); + + assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED); + assertThat(outcome.retryDisposition()).isEqualTo(RetryDisposition.RETRY_AT); + assertThat(outcome.faultScope()).isEqualTo(NotificationFaultScope.PROVIDER_BINDING); + assertThat(outcome.retryNotBefore()).contains(retryAt); + } + + @Test + void outcomeRejectsContradictoryAxes() { + assertThatThrownBy( + () -> + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.RETRY_AT, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("ACCEPTED"), + Optional.of(Instant.parse("2026-07-28T00:01:00Z")), + "attempt-correlation-42", + Optional.of("provider-message-ref-42"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.RETRY_AT, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_THROTTLED"), + Optional.empty(), + "attempt-correlation-42", + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("retryNotBefore"); + assertThatThrownBy( + () -> + new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("RESPONSE_LOST"), + Optional.empty(), + "attempt-correlation-42", + Optional.of("message-that-cannot-be-known"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void providerAndTargetOutcomesRedactOpaqueReferences() { + ProviderAttemptOutcome providerOutcome = + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + "attempt-correlation-secret", + Optional.of("provider-message-secret")); + TargetAttemptOutcome target = + new TargetAttemptOutcome(0, new NotificationDeliveryId("delivery-secret"), providerOutcome); + + assertThat(providerOutcome.toString()) + .doesNotContain("attempt-correlation-secret") + .doesNotContain("provider-message-secret"); + assertThat(target.toString()).doesNotContain("delivery-secret"); + } + + @Test + void requestResultIsAClosedUnionAndInlineTargetListIsBoundedAndImmutable() { + List mutable = + new ArrayList<>( + List.of( + new TargetAttemptOutcome( + 0, + new NotificationDeliveryId("delivery-1"), + acceptedOutcome("correlation-1", "provider-message-1")))); + NotificationRequestResult.InlineCompleted completed = + new NotificationRequestResult.InlineCompleted( + new NotificationIntentId("intent-1"), mutable); + mutable.clear(); + + assertThat(completed.outcomes()).hasSize(1); + assertThatThrownBy(() -> completed.outcomes().clear()) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy( + () -> + new NotificationRequestResult.InlineCompleted( + new NotificationIntentId("intent-1"), + java.util.stream.IntStream.range(0, 17) + .mapToObj( + index -> + new TargetAttemptOutcome( + index, + new NotificationDeliveryId("delivery-" + index), + acceptedOutcome( + "correlation-" + index, "provider-message-" + index))) + .toList())) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(NotificationRequestResult.class.getPermittedSubclasses()) + .containsExactlyInAnyOrder( + NotificationRequestResult.InlineCompleted.class, + NotificationRequestResult.AppendedDurably.class, + NotificationRequestResult.DuplicateExistingIntent.class, + NotificationRequestResult.RejectedByBusinessPolicy.class, + NotificationRequestResult.RejectedInvalidRequest.class, + NotificationRequestResult.CapabilityUnavailable.class); + } + + private static ProviderAttemptOutcome acceptedOutcome( + String correlationReference, String providerReference) { + return new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + correlationReference, + Optional.of(providerReference)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java new file mode 100644 index 0000000..f933911 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java @@ -0,0 +1,222 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Currency; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class NotificationValueContractTest { + + @Test + void identifiersAreNonBlankBoundedAndRejectControlCharacters() { + assertThatThrownBy(() -> new NotificationIntentId(" ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationDeliveryId("delivery\n1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationAttemptId("a".repeat(129))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationReceiptEventId("event/1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationKindId("PASSWORD_RESET")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationRouteId("-primary")) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(new NotificationIntentId("01J1234567890ABCDEFGHJKMNP").value()) + .isEqualTo("01J1234567890ABCDEFGHJKMNP"); + } + + @Test + void recipientReferencesAreChannelTypedAndRedacted() { + NotificationRecipientReference email = new EmailRecipientReference("recipient-email-ref-42"); + NotificationRecipientReference slack = + new SlackAudienceReference("workspace-binding-1", "audience-ref-42"); + + assertThat(email.channel()).isEqualTo(NotificationChannel.EMAIL); + assertThat(slack.channel()).isEqualTo(NotificationChannel.SLACK); + assertThat(email.toString()).doesNotContain("recipient-email-ref-42"); + assertThat(slack.toString()) + .doesNotContain("workspace-binding-1") + .doesNotContain("audience-ref-42"); + } + + @Test + void templateParametersAcceptOnlyTheClosedScalarSetAndAreImmutableAndRedacted() { + Map values = new LinkedHashMap<>(); + values.put("displayName", new NotificationTemplateValue.SafeText("Ada Lovelace")); + values.put( + "resetLink", + new NotificationTemplateValue.TrustedAbsoluteLinkReference("reset-link-ref-42")); + values.put( + "businessDate", + new NotificationTemplateValue.LocalDateValue(LocalDate.parse("2026-07-28"))); + values.put( + "expiresAt", + new NotificationTemplateValue.LocalDateTimeValue( + LocalDateTime.parse("2026-07-28T12:00:00"), ZoneId.of("Asia/Seoul"))); + values.put("attempts", new NotificationTemplateValue.IntegerValue(3)); + values.put( + "amount", + new NotificationTemplateValue.MoneyValue( + new BigDecimal("12500.00"), Currency.getInstance("KRW"))); + + NotificationTemplateParameters parameters = new NotificationTemplateParameters(values); + values.clear(); + + assertThat(parameters.values()).hasSize(6); + assertThatThrownBy( + () -> parameters.values().put("later", new NotificationTemplateValue.IntegerValue(1))) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(parameters.toString()) + .doesNotContain("Ada Lovelace") + .doesNotContain("reset-link-ref-42") + .doesNotContain("12500.00"); + } + + @Test + void templateParametersRejectUnknownShapesAndUnboundedCollectionsByType() { + assertThat( + NotificationTemplateParameters.class + .getRecordComponents()[0] + .getGenericType() + .getTypeName()) + .isEqualTo( + "java.util.Map"); + assertThatThrownBy( + () -> + new NotificationTemplateParameters( + Map.of("bad key", new NotificationTemplateValue.IntegerValue(1)))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationTemplateParameters( + java.util.stream.IntStream.range(0, 33) + .boxed() + .collect( + java.util.stream.Collectors.toMap( + index -> "parameter" + index, + index -> new NotificationTemplateValue.IntegerValue(index))))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void draftHasExactlyOneTypedRecipientAndBoundedLocaleAndTimeWindow() { + NotificationKindPolicy policy = durablePolicy(Duration.ofHours(24)); + Instant notBefore = Instant.parse("2026-07-28T00:00:00Z"); + + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + Locale.forLanguageTag("ko-KR"), + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "password-reset", + "source-operation-42", + Optional.of("tenant-42"), + "correlation-42", + Optional.of("causation-42"), + notBefore, + notBefore.plus(Duration.ofHours(2))); + + assertThat(draft.recipient()).isInstanceOf(EmailRecipientReference.class); + assertThat(draft.policy().mode()).isEqualTo(NotificationMode.DURABLE_ASYNC); + assertThat(draft.toString()) + .doesNotContain("recipient-ref-42") + .doesNotContain("Ada") + .doesNotContain("tenant-42"); + + assertThatThrownBy( + () -> + new NotificationIntentDraft( + draft.intentId(), + policy, + Locale.ROOT, + draft.recipient(), + draft.parameters(), + draft.idempotencyScope(), + draft.sourceOperationId(), + draft.tenantReference(), + draft.correlationReference(), + draft.causationReference(), + notBefore, + notBefore.plusSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationIntentDraft( + draft.intentId(), + policy, + draft.requestedLocale(), + draft.recipient(), + draft.parameters(), + draft.idempotencyScope(), + draft.sourceOperationId(), + draft.tenantReference(), + draft.correlationReference(), + draft.causationReference(), + notBefore, + notBefore.plus(Duration.ofHours(25)))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void frozenPlanCopiesOnlyApplicationOwnedValuesAndPinsSelectedLocale() { + NotificationKindPolicy policy = durablePolicy(Duration.ofHours(24)); + Instant notBefore = Instant.parse("2026-07-28T00:00:00Z"); + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + Locale.forLanguageTag("ko-KR"), + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "password-reset", + "source-operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + notBefore, + notBefore.plusSeconds(60)); + + NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.forLanguageTag("en")); + + assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH); + assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC); + assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary")); + assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada"); + } + + private static NotificationKindPolicy durablePolicy(Duration retryHorizon) { + return new NotificationKindPolicy( + new NotificationKindId("password-reset"), + NotificationChannel.EMAIL, + new NotificationRouteId("email-primary"), + new NotificationTemplateRef("password-reset", 3), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 7, + 1, + 3, + 0, + 1, + 4, + retryHorizon); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java new file mode 100644 index 0000000..3a20f38 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class ReconcileNotificationDeliveriesUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void claimThenProviderThenTokenGuardedFinalizeAndOrphanAttachRespectBoundaries() { + List trace = new ArrayList<>(); + TrackingTransactions transactions = new TrackingTransactions(trace); + RecordingStore store = new RecordingStore(trace); + NotificationReconciliationPort provider = + claim -> { + assertThat(transactions.active).isFalse(); + trace.add("provider"); + return new NotificationReconciliationPort.ReconciliationOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + new NotificationReasonCode("RECONCILED_ACCEPTED")); + }; + ReconcileNotificationDeliveriesUseCase useCase = + new ReconcileNotificationDeliveriesUseCase( + store, provider, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + ReconcileNotificationDeliveriesResult result = + useCase.handle(new ReconcileNotificationDeliveriesCommand(10, 10)); + + assertThat(trace) + .containsExactly( + "tx-begin", + "attach-orphans", + "tx-end", + "tx-begin", + "claim", + "tx-end", + "provider", + "tx-begin", + "finalize", + "tx-end"); + assertThat(result).isEqualTo(new ReconcileNotificationDeliveriesResult(1, 1, 1, 1)); + } + + @Test + void orphanOnlyCyclePerformsNoProviderIo() { + TrackingTransactions transactions = new TrackingTransactions(new ArrayList<>()); + RecordingStore store = new RecordingStore(new ArrayList<>()); + store.claims = List.of(); + final int[] providerCalls = {0}; + + ReconcileNotificationDeliveriesResult result = + new ReconcileNotificationDeliveriesUseCase( + store, + claim -> { + providerCalls[0]++; + throw new AssertionError("provider must not run for orphan-only cycle"); + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(new ReconcileNotificationDeliveriesCommand(0, 10)); + + assertThat(providerCalls[0]).isZero(); + assertThat(result.orphanAttachedCount()).isEqualTo(1); + } + + private static final class RecordingStore implements NotificationDeliveryStorePort { + + private final List trace; + private List claims = + List.of( + new ReconciliationClaim( + new NotificationDeliveryId("delivery-42"), + "reconcile-token-42", + 3, + "provider-message-42", + NOW.plusSeconds(30))); + + private RecordingStore(List trace) { + this.trace = trace; + } + + @Override + public List claimEligible(int maximumClaims, Instant now) { + return List.of(); + } + + @Override + public AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now) { + throw new UnsupportedOperationException(); + } + + @Override + public FinalizationResult finalizeAttempt( + AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now) { + throw new UnsupportedOperationException(); + } + + @Override + public List claimForReconciliation(int maximumClaims, Instant now) { + trace.add("claim"); + return claims; + } + + @Override + public ReconciliationFinalizationResult finalizeReconciliation( + ReconciliationClaim claim, + NotificationReconciliationPort.ReconciliationOutcome outcome, + Instant now) { + trace.add("finalize"); + return ReconciliationFinalizationResult.APPLIED; + } + + @Override + public int attachOrphanReceipts(int maximumAttachments, Instant now) { + trace.add("attach-orphans"); + return maximumAttachments == 0 ? 0 : 1; + } + } + + private static final class TrackingTransactions implements TransactionPort { + + private final List trace; + private boolean active; + + private TrackingTransactions(List trace) { + this.trace = trace; + } + + @Override + public T inWrite(Supplier action) { + trace.add("tx-begin"); + active = true; + try { + return action.get(); + } finally { + active = false; + trace.add("tx-end"); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java new file mode 100644 index 0000000..b8546c4 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java @@ -0,0 +1,216 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RecordNotificationWriterQuiescenceAttestationUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void signedExactQuiescenceEvidenceIsVerifiedThenRootCommitted() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + SignedNotificationWriterQuiescenceManifest manifest = quiescenceManifest(route); + NotificationWriterQuiescenceAttestationPort verifier = + (signed, expectedRoute, expectedGeneration, trustedRoute, verifiedAt) -> { + assertThat(transactions.active).isFalse(); + assertThat(signed).isSameAs(manifest); + assertThat(trustedRoute.proofRequirement()) + .isEqualTo(NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED); + return new NotificationWriterQuiescenceAttestationPort.VerifiedQuiescenceEvidence( + expectedRoute, + expectedGeneration, + signed.header().childSetDigest(), + signed.header().childCount(), + verifiedAt); + }; + RecordNotificationWriterQuiescenceAttestationOperation operation = + (command, evidence, trustedRoute, now) -> { + assertThat(transactions.active).isTrue(); + return new RecordNotificationWriterQuiescenceAttestationResult( + RecordNotificationWriterQuiescenceAttestationResult.Status.RECORDED, + command.operationToken(), + evidence.route(), + evidence.generation(), + evidence.childSetDigest()); + }; + RecordNotificationWriterQuiescenceAttestationUseCase useCase = + new RecordNotificationWriterQuiescenceAttestationUseCase( + routeSet, verifier, operation, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + RecordNotificationWriterQuiescenceAttestationResult result = + useCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-42", + manifest, + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED"))); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.status()) + .isEqualTo(RecordNotificationWriterQuiescenceAttestationResult.Status.RECORDED); + } + + @Test + void invalidSignatureOrHardBoundRouteFailsBeforePersistence() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + AtomicInteger operations = new AtomicInteger(); + RecordNotificationWriterQuiescenceAttestationUseCase invalidVerifierUseCase = + new RecordNotificationWriterQuiescenceAttestationUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, trustedRoute, verifiedAt) -> { + throw new NotificationApplicationException( + new NotificationReasonCode("EVIDENCE_SIGNATURE_INVALID"), null); + }, + (command, evidence, trustedRoute, now) -> { + operations.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + invalidVerifierUseCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-42", + quiescenceManifest(route), + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED")))) + .isInstanceOf(NotificationApplicationException.class); + + NotificationCanonicalWriterRouteSet.RouteRevision hardBoundRoute = + routeSet.canonicalRoutes().routes().get(1); + assertThatThrownBy( + () -> + invalidVerifierUseCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-43", + quiescenceManifest(hardBoundRoute), + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("forbids"); + assertThat(operations).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } + + @Test + void transportProfilesMustExactlyMatchTrustedCurrentAndRetiringRegistry() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + SignedNotificationWriterQuiescenceManifest valid = quiescenceManifest(route); + SignedNotificationWriterQuiescenceManifest drifted = + new SignedNotificationWriterQuiescenceManifest( + valid.header(), + valid.route(), + valid.drainingGeneration(), + Set.of("untrusted-profile"), + valid.nodes(), + valid.blockingPermitCount(), + valid.blockingPermitSetDigest(), + valid.permitHolderIds(), + valid.productionConsumerCount(), + valid.providerCallOpenCount()); + AtomicInteger verifierCalls = new AtomicInteger(); + RecordNotificationWriterQuiescenceAttestationUseCase useCase = + new RecordNotificationWriterQuiescenceAttestationUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, trustedRoute, verifiedAt) -> { + verifierCalls.incrementAndGet(); + throw new AssertionError("verifier must not run"); + }, + (command, evidence, trustedRoute, now) -> { + throw new AssertionError("operation must not run"); + }, + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(), + Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + useCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-44", + drifted, + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly match"); + assertThat(verifierCalls).hasValue(0); + } + + static SignedNotificationWriterQuiescenceManifest quiescenceManifest( + NotificationCanonicalWriterRouteSet.RouteRevision route) { + NotificationSignedEvidenceHeader header = evidenceHeader(2, "b".repeat(64)); + return new SignedNotificationWriterQuiescenceManifest( + header, + route, + route.predecessorGeneration(), + Set.of("legacy-http-v1"), + List.of( + new SignedNotificationWriterQuiescenceManifest.NodeQuiescence( + "node-42", true, true, true, true, true)), + 0, + "c".repeat(64), + Set.of(), + 0, + 0); + } + + static SignedNotificationWriterInventoryManifest inventoryManifest( + NotificationCanonicalWriterRouteSet.RouteRevision route) { + return new SignedNotificationWriterInventoryManifest( + evidenceHeader(1, "d".repeat(64)), + route, + route.predecessorGeneration(), + List.of( + new SignedNotificationWriterInventoryManifest.NodeInventory( + "node-42", "legacy-artifact-r1"))); + } + + private static NotificationSignedEvidenceHeader evidenceHeader( + int childCount, String childDigest) { + return new NotificationSignedEvidenceHeader( + "notification-evidence-v1", + "canonical-payload".getBytes(java.nio.charset.StandardCharsets.UTF_8), + new byte[64], + "Ed25519", + "issuer-key-42", + new byte[44], + "e".repeat(64), + new NotificationEvidenceTrustSnapshot( + "trust-r1", + NotificationEvidenceTrustSnapshot.HistoricalKeyStatus.ALLOWED, + "e".repeat(64)), + NOW.minusSeconds(30), + NOW.plusSeconds(300), + Duration.ofSeconds(10), + Duration.ofSeconds(5), + "production", + "notification-db", + "legacy-artifact-r1", + "production-consumers-r1", + "provider-call-ledger-r1", + "provider-call-ledger-snapshot-42", + childCount, + childDigest); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java new file mode 100644 index 0000000..db6f1cb --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java @@ -0,0 +1,173 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class SwitchNotificationWriterOwnershipUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void beginDrainVerifiesSignedInventoryThenRootCommitsClosedTransition() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + SignedNotificationWriterInventoryManifest manifest = + RecordNotificationWriterQuiescenceAttestationUseCaseTest.inventoryManifest(route); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + NotificationWriterInventoryEvidenceVerifierPort verifier = + (signed, expectedRoute, expectedGeneration, verifiedAt) -> + new NotificationWriterInventoryEvidence( + expectedRoute, + expectedGeneration, + signed.nodeIds(), + signed.header().childSetDigest(), + verifiedAt); + SwitchNotificationWriterOwnershipOperation operation = + (command, inventoryEvidence, trustedRoute, now) -> { + assertThat(transactions.active).isTrue(); + assertThat(inventoryEvidence).isPresent(); + return SwitchNotificationWriterOwnershipResult.applied( + command.action(), + command.route(), + NotificationWriterOwnership.LEGACY, + command.expectedGeneration(), + command.operationToken()); + }; + SwitchNotificationWriterOwnershipUseCase useCase = + new SwitchNotificationWriterOwnershipUseCase( + routeSet, verifier, operation, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + SwitchNotificationWriterOwnershipResult result = + useCase.handle( + SwitchNotificationWriterOwnershipCommand.beginDrain( + route, + 7, + "switch-operation-42", + "operator-42", + new NotificationReasonCode("BEGIN_REVIEWED_DRAIN"), + manifest)); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.state()) + .isEqualTo(SwitchNotificationWriterOwnershipResult.FenceState.DRAINING); + assertThat(result.owner()).isEqualTo(NotificationWriterOwnership.LEGACY); + assertThat(result.generation()).isEqualTo(7); + } + + @Test + void completeAndAbortDeriveOwnerAndNextGenerationWithoutTargetOwnerInput() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision quiescenceRoute = + routeSet.canonicalRoutes().routes().getFirst(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + SwitchNotificationWriterOwnershipUseCase useCase = + new SwitchNotificationWriterOwnershipUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, verifiedAt) -> { + throw new AssertionError("inventory verifier is only used for BEGIN"); + }, + (command, inventoryEvidence, trustedRoute, now) -> { + NotificationWriterOwnership owner = + command.action() + == SwitchNotificationWriterOwnershipCommand.Action.COMPLETE_SWITCH + ? NotificationWriterOwnership.CANONICAL + : NotificationWriterOwnership.LEGACY; + return SwitchNotificationWriterOwnershipResult.applied( + command.action(), + command.route(), + owner, + command.reviewedTargetGeneration(), + command.operationToken()); + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + SwitchNotificationWriterOwnershipResult complete = + useCase.handle( + SwitchNotificationWriterOwnershipCommand.completeSwitch( + quiescenceRoute, + 7, + 8, + "switch-operation-43", + "operator-42", + new NotificationReasonCode("COMPLETE_REVIEWED_SWITCH"), + Optional.of("attestation-operation-42"))); + SwitchNotificationWriterOwnershipResult abort = + useCase.handle( + SwitchNotificationWriterOwnershipCommand.abortDrain( + quiescenceRoute, + 7, + 8, + "switch-operation-44", + "operator-42", + new NotificationReasonCode("ABORT_REVIEWED_DRAIN"))); + + assertThat(complete.owner()).isEqualTo(NotificationWriterOwnership.CANONICAL); + assertThat(complete.generation()).isEqualTo(8); + assertThat(abort.owner()).isEqualTo(NotificationWriterOwnership.LEGACY); + assertThat(abort.generation()).isEqualTo(8); + assertThat( + Arrays.stream(SwitchNotificationWriterOwnershipCommand.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName)) + .noneMatch(name -> name.toLowerCase(java.util.Locale.ROOT).contains("owner")); + } + + @Test + void quiescenceRequirementAndClosedGenerationMatrixFailBeforeOperation() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + AtomicInteger operations = new AtomicInteger(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + SwitchNotificationWriterOwnershipUseCase useCase = + new SwitchNotificationWriterOwnershipUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, verifiedAt) -> { + throw new AssertionError("not called"); + }, + (command, inventoryEvidence, trustedRoute, now) -> { + operations.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.systemUTC()); + + assertThatThrownBy( + () -> + useCase.handle( + SwitchNotificationWriterOwnershipCommand.completeSwitch( + route, + 7, + 8, + "switch-operation-42", + "operator-42", + new NotificationReasonCode("COMPLETE_REVIEWED_SWITCH"), + Optional.empty()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attestation"); + assertThatThrownBy( + () -> + SwitchNotificationWriterOwnershipCommand.abortDrain( + route, + 7, + 9, + "switch-operation-42", + "operator-42", + new NotificationReasonCode("ABORT_REVIEWED_DRAIN"))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(operations).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java new file mode 100644 index 0000000..40d919e --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class TerminalizeExpiredNotificationWriterPermitsUseCaseTest { + + @Test + void terminalizationIsBoundedAuthenticatedAndRootCommittedAgainstTrustedRegistry() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + TerminalizeExpiredNotificationWriterPermitsOperation operation = + (command, trustedRoute, now) -> { + assertThat(transactions.active).isTrue(); + assertThat(trustedRoute.proofRequirement()) + .isEqualTo(NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED); + return new TerminalizeExpiredNotificationWriterPermitsResult( + TerminalizeExpiredNotificationWriterPermitsResult.Status.APPLIED, 2, "a".repeat(64)); + }; + TerminalizeExpiredNotificationWriterPermitsUseCase useCase = + new TerminalizeExpiredNotificationWriterPermitsUseCase( + routeSet, + operation, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + TerminalizeExpiredNotificationWriterPermitsResult result = + useCase.handle( + new TerminalizeExpiredNotificationWriterPermitsCommand( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + 100, + "terminalize-operation-42", + "operator-42", + new NotificationReasonCode("TERMINALIZE_EXPIRED_PERMITS"))); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.affectedCount()).isEqualTo(2); + } + + @Test + void batchAboveHundredAndUnknownRouteFailWithoutMutation() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + AtomicInteger calls = new AtomicInteger(); + TerminalizeExpiredNotificationWriterPermitsUseCase useCase = + new TerminalizeExpiredNotificationWriterPermitsUseCase( + routeSet, + (command, trustedRoute, now) -> { + calls.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.systemUTC()); + + assertThatThrownBy( + () -> + new TerminalizeExpiredNotificationWriterPermitsCommand( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + 101, + "terminalize-operation-42", + "operator-42", + new NotificationReasonCode("TERMINALIZE_EXPIRED_PERMITS"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + useCase.handle( + new TerminalizeExpiredNotificationWriterPermitsCommand( + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("unknown-route"), 1, 0), + 0, + 1, + "terminalize-operation-42", + "operator-42", + new NotificationReasonCode("TERMINALIZE_EXPIRED_PERMITS")))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(calls).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java index f7730f9..abdb1b0 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java @@ -550,6 +550,11 @@ class PublishPendingOutboxEventsUseCaseTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { return action.get(); diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java index 0281bc9..ba27696 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java @@ -20,6 +20,17 @@ class TransactionPortTest { assertThat(sideEffect).containsExactly("ran"); } + @Test + void runnableInRootWriteDelegatesToSupplierInRootWrite() { + RecordingTransactionPort port = new RecordingTransactionPort(); + List sideEffect = new ArrayList<>(); + + port.inRootWrite(() -> sideEffect.add("ran")); + + assertThat(port.invocations).containsExactly(TransactionMode.WRITE); + assertThat(sideEffect).containsExactly("ran"); + } + @Test void runnableInReadDelegatesToSupplierInRead() { RecordingTransactionPort port = new RecordingTransactionPort(); @@ -52,6 +63,16 @@ class TransactionPortTest { assertThat(port.invocations).containsExactly(TransactionMode.WRITE); } + @Test + void supplierInRootWriteReturnsActionValue() { + RecordingTransactionPort port = new RecordingTransactionPort(); + + String result = port.inRootWrite(() -> "v"); + + assertThat(result).isEqualTo("v"); + assertThat(port.invocations).containsExactly(TransactionMode.WRITE); + } + private static final class RecordingTransactionPort implements TransactionPort { private final List invocations = new ArrayList<>(); @@ -62,6 +83,12 @@ class TransactionPortTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + invocations.add(TransactionMode.WRITE); + return action.get(); + } + @Override public T inRead(Supplier action) { invocations.add(TransactionMode.READ_ONLY); diff --git a/src/build.gradle b/src/build.gradle index ead48df..12975f5 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,8 +1,10 @@ import groovy.json.JsonSlurper +import groovy.json.JsonOutput import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Jar +import java.security.MessageDigest plugins { id 'org.springframework.boot' version '4.0.0' apply false @@ -701,6 +703,1381 @@ project(':application-core').tasks.named('check') { dependsOn verifyApplicationCoreDependencyPurity } +Closure>> loadRedisReadinessCards = { File registryFile -> + if (!registryFile.isFile()) { + throw new GradleException("Missing Redis readiness registry: ${registryFile}") + } + Map> cards = new LinkedHashMap<>() + String currentCard = null + boolean rootSeen = false + boolean readingEvidence = false + int lineNumber = 0 + registryFile.eachLine('UTF-8') { String raw -> + lineNumber++ + if (raw.contains('\t')) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: tabs are not allowed") + } + String line = raw.stripTrailing() + if (line.isBlank() || line.stripLeading().startsWith('#')) { + return + } + if (!rootSeen) { + if (line != 'cards:') { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: expected cards:") + } + rootSeen = true + return + } + def cardMatch = line =~ /^ ([a-z][a-z0-9-]+):$/ + if (cardMatch.matches()) { + currentCard = cardMatch.group(1) + if (cards.containsKey(currentCard)) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: duplicate card ID ${currentCard}") + } + cards[currentCard] = [requiredEvidence: []] + readingEvidence = false + return + } + if (currentCard == null) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: card field found before a card ID") + } + if (line.startsWith(' - ')) { + if (!readingEvidence) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: list item is only valid under required-evidence") + } + String evidence = line.substring(8) + if (!(evidence in [ + 'standalone', + 'security', + 'sentinel', + 'cluster', + 'fault', + 'compatibility', + 'selected-topology' + ])) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: unsupported required evidence ${evidence}") + } + if ((cards[currentCard].requiredEvidence as List).contains(evidence)) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: duplicate evidence ${evidence}") + } + (cards[currentCard].requiredEvidence as List) << evidence + return + } + if (!line.startsWith(' ') || line.startsWith(' ')) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: unsupported indentation") + } + readingEvidence = false + String field = line.substring(4) + if (field == 'required-evidence:') { + if (cards[currentCard].evidenceDeclared == true) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: duplicate required-evidence field") + } + cards[currentCard].evidenceDeclared = true + readingEvidence = true + return + } + int separator = field.indexOf(': ') + if (separator < 1) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: expected field: value") + } + String name = field.substring(0, separator) + String value = field.substring(separator + 2) + if (value.isBlank()) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: ${name} must not be blank") + } + switch (name) { + case 'state': + if (cards[currentCard].state != null) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: duplicate state field") + } + if (!(value in ['selected', 'implemented-candidate', 'not-implemented'])) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: unsupported state ${value}") + } + cards[currentCard].state = value + break + case 'selected-topology': + if (cards[currentCard].selectedTopology != null) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: duplicate selected-topology field") + } + if (!(value in ['standalone', 'sentinel', 'cluster'])) { + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: unsupported selected-topology ${value}") + } + cards[currentCard].selectedTopology = value + break + default: + throw new GradleException( + "Malformed Redis readiness registry at line ${lineNumber}: unknown field ${name}") + } + } + if (!rootSeen) { + throw new GradleException('Redis readiness registry is missing cards:') + } + Set expected = [ + 'redis-cache', + 'redis-edge-rate-limit', + 'redis-request-replay-idempotency', + 'redis-cache-refresh-soft-lease', + 'redis-fenced-coordination', + 'redis-session' + ] as Set + if (cards.keySet() != expected) { + throw new GradleException( + "Redis readiness registry cards must be exactly ${expected}; got ${cards.keySet()}") + } + cards.each { String cardId, Map card -> + if (card.state == null) { + throw new GradleException("Redis readiness card ${cardId} has no valid state") + } + if (card.state == 'not-implemented') { + if (card.selectedTopology != null || + card.evidenceDeclared == true || + !(card.requiredEvidence as List).isEmpty()) { + throw new GradleException( + "Redis readiness card ${cardId} is not-implemented and must not declare topology or evidence") + } + return + } + if (card.selectedTopology == null || (card.requiredEvidence as List).isEmpty()) { + throw new GradleException( + "Redis readiness card ${cardId} requires topology and evidence") + } + if (!(card.requiredEvidence as List).contains('selected-topology')) { + throw new GradleException( + "Redis readiness card ${cardId} required-evidence must include selected-topology") + } + } + cards +} + +Map> redisReadinessCards = loadRedisReadinessCards( + rootProject.file('config/redis/readiness-cards.yaml')) +ext.redisReadinessCards = redisReadinessCards +Map redisReadinessTaskStems = [ + 'redis-cache' : 'Cache', + 'redis-edge-rate-limit' : 'RateLimit', + 'redis-request-replay-idempotency' : 'Idempotency', + 'redis-cache-refresh-soft-lease' : 'SoftLease', + 'redis-fenced-coordination' : 'FencedCoordination', + 'redis-session' : 'Session' +] +Map redisEvidenceTaskStems = [ + standalone : 'Standalone', + security : 'Security', + sentinel : 'Sentinel', + cluster : 'Cluster', + fault : 'Fault', + compatibility: 'Compatibility' +] +Map redisPublicReadinessTasks = [ + 'redis-cache' : 'redisCacheReadiness', + 'redis-edge-rate-limit' : 'redisRateLimitReadiness', + 'redis-request-replay-idempotency' : 'redisIdempotencyReadiness', + 'redis-cache-refresh-soft-lease' : 'redisSoftLeaseReadiness', + 'redis-fenced-coordination' : 'redisFencedCoordinationReadiness', + 'redis-session' : 'redisSessionReadiness' +] + +Map> redisCapabilityMetadata = [ + 'redis-cache' : [ + providerIds : ['redis'], + roles : ['CACHE'], + programs : [ + 'set-if-absent-with-ttl-v1', + 'replace-if-observed-with-ttl-v1', + 'region-generation-init-v1', + 'region-generation-bump-v1' + ], + keyVersions : ['cache-key-v1'], + codecVersions: ['cache-envelope-v2'], + guarantees : ['bounded standalone semantic cache and generation fencing'], + nonGuarantees: [ + 'no multi-process L1 coherence or HA topology qualification', + 'runtime-resolved image attestation and actual fault event timeline are not captured' + ], + requiredSettings: [ + [name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum', + constraint: 'disabled or redis; redis is required to activate this card'], + [name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding', + constraint: 'optional CACHE role with finite timeouts and bounds'], + [name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference', + type: 'secret-reference-name', + constraint: 'nonblank reference name; resolved secret value is never evidence'] + ] + ], + 'redis-edge-rate-limit' : [ + providerIds : ['redis'], + roles : ['COORDINATION'], + programs : [ + 'rate-fixed-window-v2', + 'rate-sliding-counter-v2', + 'rate-token-bucket-v2' + ], + keyVersions : ['rate-limit-key-v1'], + codecVersions: ['rate-limit-reply-v2'], + guarantees : ['atomic standalone quota evaluation with bounded state'], + nonGuarantees: [ + 'no Sentinel, Cluster, failover, or R3 qualification', + 'runtime-resolved image attestation and actual fault event timeline are not captured' + ], + requiredSettings: [ + [name: 'ca-skeleton.capabilities.rate-limit.provider', type: 'enum', + constraint: 'disabled or redis; checked-in registry remains release authority'], + [name: 'ca-skeleton.providers.redis.roles.coordination', + type: 'role-binding', + constraint: 'required COORDINATION role bound to one deployment'], + [name: 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference', + type: 'secret-reference-name', + constraint: 'nonblank reference name; resolved secret value is never evidence'] + ] + ], + 'redis-request-replay-idempotency' : [ + providerIds : ['redis'], + roles : ['COORDINATION'], + programs : [ + 'idempotency-claim-v1', + 'idempotency-start-v1', + 'idempotency-renew-v1', + 'idempotency-complete-v1', + 'idempotency-fail-v1', + 'idempotency-release-v1', + 'idempotency-inspect-v1' + ], + keyVersions : ['idempotency-key-v1'], + codecVersions: ['idempotency-program-schema-v2'], + guarantees : ['standalone request replay state transitions'], + nonGuarantees: [ + 'no cross-store exactly-once guarantee', + 'runtime-resolved image attestation and actual fault event timeline are not captured' + ], + requiredSettings: [ + [name: 'ca-skeleton.capabilities.idempotency.provider', type: 'enum', + constraint: 'provider selection is explicit and exclusive'], + [name: 'ca-skeleton.providers.redis.roles.coordination', + type: 'role-binding', + constraint: 'required COORDINATION role bound to one deployment'], + [name: 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference', + type: 'secret-reference-name', + constraint: 'nonblank reference name; resolved secret value is never evidence'] + ] + ], + 'redis-cache-refresh-soft-lease' : [ + providerIds : ['redis'], + roles : ['CACHE'], + programs : ['cache-refresh-claim-v1', 'compare-and-delete-v1'], + keyVersions : ['cache-refresh-key-v1'], + codecVersions: ['cache-refresh-owner-v1'], + guarantees : ['bounded duplicate refresh suppression'], + nonGuarantees: [ + 'not a correctness lock and no fencing token', + 'runtime-resolved image attestation and actual fault event timeline are not captured' + ], + requiredSettings: [ + [name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum', + constraint: 'disabled or redis; redis activates the cache-owned soft lease'], + [name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding', + constraint: 'optional CACHE role with finite timeouts and bounds'], + [name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference', + type: 'secret-reference-name', + constraint: 'nonblank reference name; resolved secret value is never evidence'] + ] + ], + 'redis-fenced-coordination' : [ + providerIds : [], + roles : ['COORDINATION'], + programs : [], + keyVersions : [], + codecVersions: [], + guarantees : [], + nonGuarantees: ['provider and stale fencing-token rejection are not implemented'], + requiredSettings: [] + ], + 'redis-session' : [ + providerIds : ['redis-session'], + roles : ['SESSION'], + programs : [ + 'session-create-v1', + 'session-inspect-v1', + 'session-save-if-live-v1', + 'session-touch-if-live-v1', + 'session-tombstone-and-delete-v1', + 'session-rotate-v1' + ], + keyVersions : ['session-key-v1'], + codecVersions: ['session-envelope-v2', 'session-envelope-v1-read'], + guarantees : ['standalone versioned session repository and stale-save rejection'], + nonGuarantees: [ + 'same-JVM two-client evidence is not multi-process or pod qualification', + 'runtime-resolved image attestation and actual fault event timeline are not captured' + ], + requiredSettings: [ + [name: 'ca-skeleton.security.auth-mode', type: 'enum', + constraint: 'session mode is explicit'], + [name: 'ca-skeleton.providers.redis.roles.session', type: 'role-binding', + constraint: 'required SESSION role bound to one standalone deployment'], + [name: 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference', + type: 'secret-reference-name', + constraint: 'nonblank reference name; resolved secret value is never evidence'] + ] + ] +] +if (redisCapabilityMetadata.keySet() != redisReadinessCards.keySet()) { + throw new GradleException( + "Redis capability metadata IDs must equal readiness cards; metadata=${redisCapabilityMetadata.keySet()}, cards=${redisReadinessCards.keySet()}") +} +ext.redisCapabilityMetadata = redisCapabilityMetadata + +def fullGitRevision = providers.exec { + commandLine 'git', 'rev-parse', 'HEAD' + ignoreExitValue = true +}.standardOutput.asText.map { it.trim() } +String checkedOutHeadRevision = fullGitRevision.getOrElse('') +if (!(checkedOutHeadRevision ==~ /[0-9a-f]{40}/)) { + throw new GradleException( + 'Redis evidence requires an exact 40-character checked-out Git HEAD.') +} +String redisEvidenceSourceRevision = providers.environmentVariable('GITHUB_SHA') + .orElse(providers.environmentVariable('GIT_SHA')) + .getOrElse(checkedOutHeadRevision) +if (!(redisEvidenceSourceRevision ==~ /[0-9a-f]{40}/)) { + throw new GradleException( + 'Redis evidence requires the exact 40-character commit SHA from Git or GITHUB_SHA/GIT_SHA.') +} +if (redisEvidenceSourceRevision != checkedOutHeadRevision) { + throw new GradleException( + "Redis evidence source revision ${redisEvidenceSourceRevision} does not equal checked-out HEAD ${checkedOutHeadRevision}.") +} +ext.redisEvidenceSourceRevision = redisEvidenceSourceRevision +def gitStatusPorcelain = providers.exec { + commandLine 'git', 'status', '--porcelain', '--untracked-files=normal' + ignoreExitValue = true +}.standardOutput.asText.map { it } +String redisEvidenceSourceTreeState = gitStatusPorcelain.getOrElse('').isBlank() + ? 'CLEAN' + : 'DIRTY' +ext.redisEvidenceSourceTreeState = redisEvidenceSourceTreeState + +Closure redisSha256 = { File file -> + if (!file.isFile()) { + throw new GradleException("Redis evidence digest input is missing: ${file}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + file.withInputStream { stream -> + byte[] buffer = new byte[8192] + int read + while ((read = stream.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read) + } + } + } + "sha256:${digest.digest().encodeHex()}" +} + +Closure redisAggregateSha256 = { Collection files -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + List sortedFiles = files.toSorted { + rootProject.projectDir.toPath().relativize(it.toPath()).toString() + } + Set paths = new LinkedHashSet<>() + sortedFiles.each { File file -> + if (!file.isFile()) { + throw new GradleException("Redis evidence digest input is missing: ${file}") + } + if (java.nio.file.Files.isSymbolicLink(file.toPath())) { + throw new GradleException("Redis evidence digest input must not be a symlink: ${file}") + } + java.nio.file.Path normalized = file.toPath().toAbsolutePath().normalize() + java.nio.file.Path root = rootProject.projectDir.toPath().toAbsolutePath().normalize() + if (!normalized.startsWith(root)) { + throw new GradleException("Redis evidence digest input escapes the source root: ${file}") + } + String relative = root.relativize(normalized).toString() + if (!paths.add(relative)) { + throw new GradleException("Duplicate Redis evidence digest path: ${relative}") + } + byte[] pathBytes = relative.getBytes('UTF-8') + byte[] contentBytes = file.bytes + digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(pathBytes.length).array()) + digest.update(pathBytes) + digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array()) + digest.update(contentBytes) + } + "sha256:${digest.digest().encodeHex()}" +} + +Closure> redisEvidenceDigests = { + File registry = rootProject.file('config/redis/readiness-cards.yaml') + File images = rootProject.file('gradle/redis-test-images.properties') + File redisResourceRoot = rootProject.file( + 'adapter/outbound/cache-redis/src/main/resources') + File redisResourceDirectory = new File(redisResourceRoot, 'redis') + List programAssets = fileTree(redisResourceDirectory) { + include '*.json' + include 'scripts/*.lua' + }.files.toList() + Set referencedScripts = new LinkedHashSet<>() + Closure validateScriptReferences + validateScriptReferences = { Object node -> + if (node instanceof Map) { + Map object = node as Map + if (object.containsKey('scriptResource') || object.containsKey('sha256')) { + if (!(object.scriptResource instanceof String) || + !(object.sha256 instanceof String) || + !(object.sha256 ==~ /[0-9a-f]{64}/)) { + throw new GradleException( + 'Redis program metadata must pair scriptResource with lowercase SHA-256') + } + File script = new File(redisResourceRoot, object.scriptResource as String) + java.nio.file.Path normalized = script.toPath().toAbsolutePath().normalize() + java.nio.file.Path resourceRoot = redisResourceRoot.toPath() + .toAbsolutePath().normalize() + if (!normalized.startsWith(resourceRoot) || + !script.isFile() || + java.nio.file.Files.isSymbolicLink(script.toPath())) { + throw new GradleException( + "Redis program script reference is missing or escapes resources: ${object.scriptResource}") + } + String actual = redisSha256(script).substring('sha256:'.length()) + if (actual != object.sha256) { + throw new GradleException( + "Redis program script digest mismatch for ${object.scriptResource}") + } + referencedScripts.add(script.canonicalFile) + } + object.values().each { validateScriptReferences(it) } + } else if (node instanceof Collection) { + (node as Collection).each { validateScriptReferences(it) } + } + } + programAssets.findAll { it.name.endsWith('.json') }.each { File manifest -> + validateScriptReferences(new JsonSlurper().parse(manifest)) + } + Set allScripts = programAssets.findAll { + it.name.endsWith('.lua') + }.collect { it.canonicalFile } as Set + if (referencedScripts != allScripts) { + throw new GradleException( + "Redis program bundle scripts must be referenced exactly; missing=${allScripts - referencedScripts}, unknown=${referencedScripts - allScripts}") + } + Map safeConfigurationProjection = redisCapabilityMetadata.collectEntries { + String cardId, Map metadata -> + [(cardId): [ + readiness : redisReadinessCards[cardId].state, + selectedTopology: redisReadinessCards[cardId].selectedTopology, + providerIds : metadata.providerIds, + roles : metadata.roles, + programIds : metadata.programs, + keyVersions : metadata.keyVersions, + codecVersions : metadata.codecVersions, + guarantees : metadata.guarantees, + nonGuarantees : metadata.nonGuarantees, + requiredSettings: metadata.requiredSettings, + evidenceProfile : redisReadinessCards[cardId].requiredEvidence + ]] + } + byte[] projectionBytes = JsonOutput.toJson(safeConfigurationProjection).getBytes('UTF-8') + String configurationDigest = "sha256:${MessageDigest.getInstance('SHA-256') + .digest(projectionBytes).encodeHex()}" + [ + registrySha256 : redisSha256(registry), + imageRegistrySha256: redisSha256(images), + programSetSha256 : redisAggregateSha256(programAssets), + configurationSha256: configurationDigest + ] +} +ext.redisEvidenceDigests = redisEvidenceDigests + +def redisControlDirectory = layout.buildDirectory.dir('redis-evidence/control') +def redisCiMatrixFile = layout.buildDirectory.file( + 'redis-evidence/control/redis-readiness-matrix.json') +def verifyRedisReadinessRegistryStrictness = tasks.register( + 'verifyRedisReadinessRegistryStrictness') { + group = 'redis verification' + description = 'Runs malformed registry fixtures through the CI/build canonical strict parser.' + inputs.file rootProject.file('config/redis/readiness-cards.yaml') + doLast { + String canonical = rootProject.file('config/redis/readiness-cards.yaml').getText('UTF-8') + Map malformed = [ + wrongRoot: canonical.replaceFirst('cards:', 'capabilities:'), + duplicateCard: canonical.replace( + ' redis-cache:\n', + ' redis-cache:\n redis-cache:\n'), + duplicateField: canonical.replaceFirst( + ' state: implemented-candidate', + ' state: implemented-candidate\n state: implemented-candidate'), + unknownField: canonical.replaceFirst( + ' state: implemented-candidate', + ' unknown-field: value\n state: implemented-candidate'), + invalidState: canonical.replaceFirst( + 'state: implemented-candidate', + 'state: candidate'), + invalidTopology: canonical.replaceFirst( + 'selected-topology: standalone', + 'selected-topology: replicated'), + invalidEvidence: canonical.replaceFirst( + ' - standalone', + ' - unsupported'), + duplicateEvidence: canonical.replaceFirst( + ' - standalone', + ' - standalone\n - standalone'), + notImplementedMetadata: canonical.replace( + ' redis-fenced-coordination:\n state: not-implemented\n', + ' redis-fenced-coordination:\n' + + ' state: not-implemented\n' + + ' selected-topology: standalone\n' + + ' required-evidence:\n' + + ' - selected-topology\n'), + notImplementedEmptyEvidence: canonical.replace( + ' redis-fenced-coordination:\n state: not-implemented\n', + ' redis-fenced-coordination:\n' + + ' state: not-implemented\n' + + ' required-evidence:\n'), + missingTopologyMarker: canonical.replaceFirst( + ' - selected-topology\\n', + ''), + unsupportedIndentation: canonical.replaceFirst( + ' state: implemented-candidate', + ' state: implemented-candidate'), + extraCard: canonical + + ' redis-unknown:\n' + + ' state: not-implemented\n', + missingCard: canonical.replace( + ' redis-fenced-coordination:\n state: not-implemented\n', + ''), + blankCard: canonical.replaceFirst( + ' redis-cache:', + ' :') + ] + malformed.each { String fixtureName, String fixtureText -> + File fixture = new File(temporaryDir, "${fixtureName}.yaml") + fixture.setText(fixtureText, 'UTF-8') + boolean rejected = false + try { + loadRedisReadinessCards(fixture) + } catch (GradleException expected) { + rejected = true + } + if (!rejected) { + throw new GradleException( + "Strict Redis readiness parser accepted malformed fixture ${fixtureName}") + } + } + logger.lifecycle( + "verifyRedisReadinessRegistryStrictness: rejected ${malformed.size()} malformed fixtures") + } +} +def verifyRedisCapabilityMetadata = tasks.register('verifyRedisCapabilityMetadata') { + group = 'redis verification' + description = 'Validates generated card provider, program, setting, and non-guarantee truth.' + File redisSource = rootProject.file( + 'adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis') + inputs.files fileTree(redisSource) { + include '**/*.java' + } + inputs.files fileTree(rootProject.file( + 'adapter/outbound/cache-redis/src/main/resources/redis')) { + include '*.json' + } + doLast { + Map> expectedProviders = [ + 'redis-cache' : ['redis'], + 'redis-edge-rate-limit' : ['redis'], + 'redis-request-replay-idempotency': ['redis'], + 'redis-cache-refresh-soft-lease' : ['redis'], + 'redis-fenced-coordination' : [], + 'redis-session' : ['redis-session'] + ] + redisCapabilityMetadata.each { String cardId, Map metadata -> + if (metadata.providerIds != expectedProviders[cardId]) { + throw new GradleException( + "${cardId}: capability provider IDs do not match canonical selection values") + } + } + String canonicalConfig = new File(redisSource, 'RedisCanonicalConfig.java') + .getText('UTF-8') + Map selectionContracts = [ + 'redis-cache' : + '"ca-skeleton.capabilities.cache.bindings.default", "redis"', + 'redis-edge-rate-limit' : + '"ca-skeleton.capabilities.rate-limit.provider", "redis"', + 'redis-request-replay-idempotency': + '"ca-skeleton.capabilities.idempotency.provider", "redis"', + 'redis-cache-refresh-soft-lease' : + '"ca-skeleton.capabilities.cache.bindings.default", "redis"', + 'redis-session' : + '"ca-skeleton.security.auth-mode", "redis-session"' + ] + selectionContracts.each { String cardId, String sourceContract -> + if (!canonicalConfig.contains(sourceContract)) { + throw new GradleException( + "${cardId}: canonical provider selection contract is missing: ${sourceContract}") + } + } + String programIdSource = new File(redisSource, 'RedisProgramId.java').getText('UTF-8') + def programMatcher = programIdSource =~ /(?s)([A-Z][A-Z0-9_]+)\s*\(\s*"([^"]+)"/ + Map enumByExternalId = new LinkedHashMap<>() + programMatcher.each { ignored, String enumName, String externalId -> + enumByExternalId[externalId] = enumName + } + Set claimedPrograms = redisCapabilityMetadata.values().collectMany { + it.programs as List + } as Set + Set unknownPrograms = claimedPrograms.findAll { + !enumByExternalId.containsKey(it) + } as Set + if (!unknownPrograms.isEmpty()) { + throw new GradleException( + "Redis capability cards claim unknown program IDs: ${unknownPrograms}") + } + Map> implementationSources = [ + 'redis-cache': [ + 'RedisStringCacheRegion.java', + 'RedisCacheConsistencyStore.java', + 'RedisAtomicPrimitives.java' + ], + 'redis-edge-rate-limit': [ + 'RedisEdgeRateLimitProvider.java' + ], + 'redis-request-replay-idempotency': [ + 'RedisIdempotencyStoreProvider.java' + ], + 'redis-cache-refresh-soft-lease': [ + 'RedisCacheRefreshCoordinator.java' + ], + 'redis-session': [ + 'RedisLuaVersionedSessionStore.java' + ] + ] + implementationSources.each { String cardId, List sources -> + String implementation = sources.collect { + new File(redisSource, it).getText('UTF-8') + }.join('\n') + (redisCapabilityMetadata[cardId].programs as List).each { String programId -> + String enumName = enumByExternalId[programId] + if (!implementation.contains("RedisProgramId.${enumName}")) { + throw new GradleException( + "${cardId}: claimed program ${programId} is not referenced by its implementation") + } + } + } + Map settingSourceContracts = [ + 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference': + 'RedisCanonicalCacheSettings.java', + 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference': + 'RedisRateLimitSettings.java', + 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference': + 'RedisIdempotencySettings.java', + 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference': + 'RedisSessionSettings.java' + ] + settingSourceContracts.each { String settingName, String sourceName -> + String source = new File(redisSource, sourceName).getText('UTF-8') + String prefix = settingName.substring(0, settingName.lastIndexOf('.')) + if (!source.contains("@ConfigurationProperties(prefix = \"${prefix}\")") || + !source.contains('String keyHmacSecretReference')) { + throw new GradleException( + "Capability card setting is not backed by typed settings: ${settingName}") + } + } + List declaredSettingNames = redisCapabilityMetadata.values().collectMany { + (it.requiredSettings as List>).collect { setting -> setting.name } + } + if (declaredSettingNames.any { it.contains('.roles.CACHE') }) { + throw new GradleException( + 'Generated Redis setting names must use canonical lowercase map keys') + } + Map> expectedSettingNames = [ + 'redis-cache': [ + 'ca-skeleton.capabilities.cache.bindings.default', + 'ca-skeleton.providers.redis.roles.cache', + 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference' + ] as Set, + 'redis-edge-rate-limit': [ + 'ca-skeleton.capabilities.rate-limit.provider', + 'ca-skeleton.providers.redis.roles.coordination', + 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference' + ] as Set, + 'redis-request-replay-idempotency': [ + 'ca-skeleton.capabilities.idempotency.provider', + 'ca-skeleton.providers.redis.roles.coordination', + 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference' + ] as Set, + 'redis-cache-refresh-soft-lease': [ + 'ca-skeleton.capabilities.cache.bindings.default', + 'ca-skeleton.providers.redis.roles.cache', + 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference' + ] as Set, + 'redis-fenced-coordination': [] as Set, + 'redis-session': [ + 'ca-skeleton.security.auth-mode', + 'ca-skeleton.providers.redis.roles.session', + 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference' + ] as Set + ] + redisCapabilityMetadata.each { String cardId, Map metadata -> + Set actual = (metadata.requiredSettings as List>) + .collect { it.name } as Set + if (actual != expectedSettingNames[cardId]) { + throw new GradleException( + "${cardId}: generated required settings are incomplete or non-canonical; expected=${expectedSettingNames[cardId]}, actual=${actual}") + } + } + String bootstrapConfiguration = rootProject.file( + 'app-bootstrap/src/main/resources/application.yml').getText('UTF-8') + if (!bootstrapConfiguration.contains( + 'ca-skeleton.providers.redis.roles.cache')) { + throw new GradleException( + 'Canonical lowercase ca-skeleton.providers.redis.roles.cache binding is missing') + } + Map fenced = redisCapabilityMetadata['redis-fenced-coordination'] + if (!(fenced.providerIds as List).isEmpty() || + !(fenced.programs as List).isEmpty() || + !(fenced.guarantees as List).isEmpty()) { + throw new GradleException( + 'redis-fenced-coordination must not claim a provider, program, or guarantee') + } + } +} +tasks.register('writeRedisCiMatrix') { + group = 'redis verification' + description = 'Writes the strict, deterministic Redis readiness matrix consumed by CI.' + dependsOn verifyRedisCapabilityMetadata + dependsOn verifyRedisReadinessRegistryStrictness + inputs.file rootProject.file('config/redis/readiness-cards.yaml') + inputs.file rootProject.file('gradle/redis-test-images.properties') + inputs.files fileTree(rootProject.file( + 'adapter/outbound/cache-redis/src/main/resources/redis')) { + include '*.json' + include 'scripts/*.lua' + } + outputs.dir redisControlDirectory + outputs.upToDateWhen { false } + doLast { + Closure> resolvedEvidence = { Map card -> + Set resolved = new LinkedHashSet<>(card.requiredEvidence as List) + if (resolved.remove('selected-topology')) { + resolved.add(card.selectedTopology as String) + } + resolved.toList().sort() + } + List> selected = [] + List> candidates = [] + redisReadinessCards.each { String cardId, Map card -> + Map entry = [ + cardId : cardId, + readinessTask : redisPublicReadinessTasks[cardId], + selectedTopology : card.selectedTopology, + resolvedEvidence : resolvedEvidence(card) + ] + if (card.state == 'selected') { + selected << entry + } + if (card.state in ['selected', 'implemented-candidate']) { + candidates << entry + } + } + selected.sort { it.cardId } + candidates.sort { it.cardId } + Map matrix = [ + schemaVersion : 1, + sourceRevision : redisEvidenceSourceRevision, + sourceTreeState : redisEvidenceSourceTreeState, + releaseQualification : 'NOT_CLAIMED', + registryDigest : redisEvidenceDigests().registrySha256, + imageRegistryDigest : redisEvidenceDigests().imageRegistrySha256, + programBundleDigest : redisEvidenceDigests().programSetSha256, + configProjectionDigest: redisEvidenceDigests().configurationSha256, + selectedCount : selected.size(), + selected : selected, + implementedCandidates : candidates, + topologyJobs : [ + sentinel: candidates.any { it.selectedTopology == 'sentinel' }, + cluster : candidates.any { it.selectedTopology == 'cluster' } + ] + ] + File controlDirectory = redisControlDirectory.get().asFile + controlDirectory.mkdirs() + File output = redisCiMatrixFile.get().asFile + output.setText(JsonOutput.prettyPrint(JsonOutput.toJson(matrix)) + '\n', 'UTF-8') + File cardsDirectory = new File(controlDirectory, 'capability-cards') + cardsDirectory.mkdirs() + Map digests = redisEvidenceDigests() + redisReadinessCards.keySet().toList().sort().each { String cardId -> + Map card = redisReadinessCards[cardId] + Map metadata = redisCapabilityMetadata[cardId] + Map generatedCard = [ + schemaVersion : 1, + cardId : cardId, + readiness : card.state, + selectionResult : card.state == 'selected' ? 'SELECTED' : 'NOT_SELECTED', + releaseQualification: 'NOT_CLAIMED', + promotionTopology : card.selectedTopology, + sourceRevision : redisEvidenceSourceRevision, + sourceTreeState : redisEvidenceSourceTreeState, + digests : digests, + minimumRedisVersion : '7.2', + providerIds : metadata.providerIds, + roles : metadata.roles, + programIds : metadata.programs, + keyVersions : metadata.keyVersions, + codecVersions : metadata.codecVersions, + guarantees : metadata.guarantees, + nonGuarantees : metadata.nonGuarantees, + requiredSettings : metadata.requiredSettings, + evidenceProfile : card.requiredEvidence + ] + new File(cardsDirectory, "${cardId}.json").setText( + JsonOutput.prettyPrint(JsonOutput.toJson(generatedCard)) + '\n', 'UTF-8') + } + List controlFiles = [output] + controlFiles.addAll(cardsDirectory.listFiles().toList()) + File checksumFile = new File(controlDirectory, 'checksums.sha256') + checksumFile.setText(controlFiles.toSorted { it.name }.collect { File file -> + String relative = controlDirectory.toPath().relativize(file.toPath()).toString() + "${redisSha256(file).substring('sha256:'.length())} ${relative}" + }.join('\n') + '\n', 'UTF-8') + logger.lifecycle("writeRedisCiMatrix: ${output}") + } +} + +def verifyRedisSelectedEvidenceArtifacts = tasks.register( + 'verifyRedisSelectedEvidenceArtifacts') { + group = 'redis verification' + description = 'Reconciles downloaded sanitized evidence for every selected Redis card.' + dependsOn tasks.named('writeRedisCiMatrix') + doLast { + File expectedControlDirectory = redisControlDirectory.get().asFile + String controlDirectoryProperty = providers.gradleProperty('redisControlDirectory') + .getOrElse('') + File suppliedControlDirectory = controlDirectoryProperty.isBlank() + ? expectedControlDirectory + : rootProject.file(controlDirectoryProperty) + if (!suppliedControlDirectory.isDirectory()) { + throw new GradleException( + "Redis readiness control directory is missing: ${suppliedControlDirectory}") + } + Set expectedControlPaths = [ + 'redis-readiness-matrix.json', + 'checksums.sha256' + ] as Set + redisReadinessCards.keySet().each { + expectedControlPaths.add("capability-cards/${it}.json") + } + List suppliedControlFiles = fileTree(suppliedControlDirectory).files.toList() + Set suppliedControlPaths = suppliedControlFiles.collect { + suppliedControlDirectory.toPath().relativize(it.toPath()).toString() + } as Set + if (suppliedControlPaths != expectedControlPaths) { + throw new GradleException( + "Redis control artifact file set mismatch; expected=${expectedControlPaths}, actual=${suppliedControlPaths}") + } + suppliedControlFiles.each { File file -> + if (file.length() > 1_048_576L || + java.nio.file.Files.isSymbolicLink(file.toPath()) || + !file.toPath().toRealPath().startsWith( + suppliedControlDirectory.toPath().toRealPath())) { + throw new GradleException( + "Redis control artifact is oversized, symlinked, or path-escaping: ${file}") + } + } + File suppliedChecksums = new File(suppliedControlDirectory, 'checksums.sha256') + Map checksumEntries = new LinkedHashMap<>() + suppliedChecksums.eachLine('UTF-8') { String line -> + def match = line =~ /^([0-9a-f]{64}) ([a-z0-9.\/-]+)$/ + if (!match.matches() || + checksumEntries.put(match.group(2), match.group(1)) != null) { + throw new GradleException( + "Malformed or duplicate Redis control checksum line: ${line}") + } + } + Set checksummedPaths = new LinkedHashSet<>(expectedControlPaths) + checksummedPaths.remove('checksums.sha256') + if (checksumEntries.keySet() != checksummedPaths) { + throw new GradleException( + "Redis control checksum set mismatch; expected=${checksummedPaths}, actual=${checksumEntries.keySet()}") + } + checksumEntries.each { String relative, String expectedSha -> + String actualSha = redisSha256( + new File(suppliedControlDirectory, relative)) + .substring('sha256:'.length()) + if (actualSha != expectedSha) { + throw new GradleException( + "Redis control checksum mismatch for ${relative}") + } + } + Map expectedMatrix = new JsonSlurper().parse( + redisCiMatrixFile.get().asFile) as Map + Map suppliedMatrix = new JsonSlurper().parse( + new File(suppliedControlDirectory, 'redis-readiness-matrix.json')) as Map + if (suppliedMatrix != expectedMatrix || + suppliedMatrix.sourceRevision != redisEvidenceSourceRevision || + suppliedMatrix.sourceTreeState != redisEvidenceSourceTreeState || + suppliedMatrix.releaseQualification != 'NOT_CLAIMED') { + throw new GradleException( + 'Downloaded Redis control matrix does not match this exact source revision and registry') + } + redisReadinessCards.each { String cardId, Map card -> + Map expectedCard = new JsonSlurper().parse( + new File(expectedControlDirectory, "capability-cards/${cardId}.json")) + as Map + Map suppliedCard = new JsonSlurper().parse( + new File(suppliedControlDirectory, "capability-cards/${cardId}.json")) + as Map + if (suppliedCard != expectedCard || + suppliedCard.releaseQualification != 'NOT_CLAIMED' || + suppliedCard.sourceTreeState != redisEvidenceSourceTreeState || + suppliedCard.readiness != card.state) { + throw new GradleException( + "Downloaded Redis capability card is stale or malformed: ${cardId}") + } + } + Map> selectedCards = redisReadinessCards.findAll { + ignored, card -> card.state == 'selected' + } + if ((suppliedMatrix.selectedCount as Number).intValue() != selectedCards.size()) { + throw new GradleException( + 'Redis control selectedCount does not match the strict checked-in registry') + } + String ciResultFileProperty = providers.gradleProperty('redisCiResultFile') + .getOrElse('') + if (!ciResultFileProperty.isBlank()) { + File ciResultFile = rootProject.file(ciResultFileProperty) + if (!ciResultFile.isFile() || + ciResultFile.length() > 65_536L || + java.nio.file.Files.isSymbolicLink(ciResultFile.toPath())) { + throw new GradleException( + "Redis CI result artifact is missing, oversized, or symlinked: ${ciResultFile}") + } + Map ciResult = new JsonSlurper().parse(ciResultFile) + as Map + if (ciResult.keySet() != [ + 'schemaVersion', + 'runId', + 'selectedCount', + 'selectedJobResult', + 'selectedArtifactNames' + ] as Set || + ciResult.schemaVersion != 1 || + ciResult.selectedCount != selectedCards.size() || + !(ciResult.runId ==~ /[1-9][0-9]{0,19}/)) { + throw new GradleException( + 'Redis CI result artifact has malformed count, run, or schema metadata') + } + String expectedJobResult = selectedCards.isEmpty() ? 'skipped' : 'success' + Set expectedArtifactNames = selectedCards.keySet().collect { + "redis-selected-${it}" + } as Set + Set actualArtifactNames = ciResult.selectedArtifactNames as Set + if (ciResult.selectedJobResult != expectedJobResult || + actualArtifactNames != expectedArtifactNames || + (ciResult.selectedArtifactNames as List).size() != + actualArtifactNames.size()) { + throw new GradleException( + "Redis CI selected job/artifact inventory mismatch; expectedResult=${expectedJobResult}, actualResult=${ciResult.selectedJobResult}, expectedArtifacts=${expectedArtifactNames}, actualArtifacts=${actualArtifactNames}") + } + } else if (!selectedCards.isEmpty() || + providers.environmentVariable('GITHUB_ACTIONS').getOrElse('') == 'true') { + throw new GradleException( + 'Redis CI/future selected reconciliation requires -PredisCiResultFile=') + } + String evidenceDirectoryProperty = providers.gradleProperty('redisEvidenceDirectory') + .getOrElse('') + if (selectedCards.isEmpty()) { + if (!evidenceDirectoryProperty.isBlank()) { + File unexpectedDirectory = rootProject.file(evidenceDirectoryProperty) + if (unexpectedDirectory.isDirectory() && + !fileTree(unexpectedDirectory).matching { + include '**/manifest.json' + }.files.isEmpty()) { + throw new GradleException( + 'No Redis card is selected but downloaded selected evidence manifests were supplied') + } + } + logger.lifecycle( + 'verifyRedisSelectedEvidenceArtifacts: selectedCount=0; explicit no-evidence branch, no R2 claim.') + return + } + if (evidenceDirectoryProperty.isBlank()) { + throw new GradleException( + 'Selected Redis cards require -PredisEvidenceDirectory=') + } + File evidenceDirectory = rootProject.file(evidenceDirectoryProperty) + if (!evidenceDirectory.isDirectory()) { + throw new GradleException( + "Redis selected evidence directory is missing: ${evidenceDirectory}") + } + Set allowedEvidenceFileNames = [ + 'manifest.json', + 'capability-card.json', + 'topology-fault-timeline.json' + ] as Set + List evidenceFiles = fileTree(evidenceDirectory).files.toList() + evidenceFiles.each { File file -> + if (!allowedEvidenceFileNames.contains(file.name) || + file.length() > 1_048_576L || + java.nio.file.Files.isSymbolicLink(file.toPath()) || + !file.toPath().toRealPath().startsWith( + evidenceDirectory.toPath().toRealPath())) { + throw new GradleException( + "Redis selected artifact contains an unexpected, oversized, symlinked, or path-escaping file: ${file}") + } + } + Set manifestParents = evidenceFiles.findAll { + it.name == 'manifest.json' + }.collect { + it.parentFile.canonicalFile + } as Set + if (evidenceFiles.size() != manifestParents.size() * allowedEvidenceFileNames.size() || + evidenceFiles.any { + !manifestParents.contains(it.parentFile.canonicalFile) + } || + manifestParents.any { File parent -> + parent.listFiles().findAll { it.isFile() }.collect { + it.name + } as Set != allowedEvidenceFileNames || + parent.listFiles().any { it.isDirectory() } + }) { + throw new GradleException( + 'Redis selected evidence must be an exact set of three allowlisted files per manifest parent') + } + List> manifests = fileTree(evidenceDirectory).matching { + include '**/manifest.json' + }.files.toSorted().collect { File manifest -> + Map parsed = new JsonSlurper().parse(manifest) as Map + parsed.__file = manifest + parsed + } + if (manifests.any { it.cardId == null }) { + throw new GradleException( + 'Downloaded selected evidence must not contain generic or unowned manifests') + } + Map expectedDigests = redisEvidenceDigests() + Set manifestFields = [ + 'schemaVersion', + 'taskPath', + 'tagExpression', + 'cardId', + 'cardState', + 'selectedTopology', + 'evidenceCategory', + 'outcome', + 'tests', + 'runtimeImageAttestation', + 'actualEventTimeline', + 'releaseQualification', + 'sourceRevision', + 'sourceTreeState', + 'digests', + 'companionSha256' + ] as Set + Set testFields = [ + 'discovered', + 'executed', + 'passed', + 'failed', + 'errors', + 'skipped' + ] as Set + Set capabilityFields = [ + 'schemaVersion', + 'cardId', + 'readiness', + 'releaseQualification', + 'promotionTopology', + 'sourceRevision', + 'sourceTreeState', + 'digests', + 'minimumRedisVersion', + 'providerIds', + 'roles', + 'programIds', + 'keyVersions', + 'codecVersions', + 'guarantees', + 'nonGuarantees', + 'requiredSettings', + 'evidenceProfile' + ] as Set + Set timelineFields = [ + 'schemaVersion', + 'taskName', + 'cardId', + 'topology', + 'evidence', + 'timelineKind', + 'actualEventTimeline', + 'sourceRevision', + 'sourceTreeState', + 'digests', + 'events' + ] as Set + selectedCards.each { String cardId, Map card -> + Set expectedEvidence = new LinkedHashSet<>( + card.requiredEvidence as List) + if (expectedEvidence.remove('selected-topology')) { + expectedEvidence.add(card.selectedTopology as String) + } + List> cardManifests = manifests.findAll { + it.cardId == cardId + } + List> capabilityManifests = cardManifests.findAll { + it.evidenceCategory == null + } + if (capabilityManifests.size() != 1) { + throw new GradleException( + "${cardId}: expected exactly one capability manifest; got ${capabilityManifests.size()}") + } + expectedEvidence.each { String evidence -> + List> matching = cardManifests.findAll { + it.evidenceCategory == evidence + } + if (matching.size() != 1) { + throw new GradleException( + "${cardId}/${evidence}: expected exactly one evidence manifest; got ${matching.size()}") + } + } + Set actualEvidence = cardManifests.findAll { + it.evidenceCategory != null + }.collect { it.evidenceCategory as String } as Set + if (actualEvidence != expectedEvidence) { + throw new GradleException( + "${cardId}: evidence mismatch; expected=${expectedEvidence}, actual=${actualEvidence}") + } + cardManifests.each { Map manifest -> + File manifestFile = manifest.__file as File + Set actualManifestFields = new LinkedHashSet<>(manifest.keySet()) + actualManifestFields.remove('__file') + Map tests = manifest.tests as Map + if (actualManifestFields != manifestFields || + tests == null || + tests.keySet() != testFields || + manifest.schemaVersion != 1 || + manifest.outcome != 'executed' || + (tests.discovered as Number).longValue() <= 0L || + (tests.executed as Number).longValue() <= 0L || + (tests.passed as Number).longValue() <= 0L || + (tests.failed as Number).longValue() != 0L || + (tests.errors as Number).longValue() != 0L || + (tests.skipped as Number).longValue() != 0L) { + throw new GradleException( + "${manifestFile}: malformed, non-executed, zero-test, failed, or skipped Redis evidence manifest") + } + if (manifest.cardState != 'selected' || + manifest.selectedTopology != card.selectedTopology || + manifest.sourceRevision != redisEvidenceSourceRevision || + manifest.sourceTreeState != redisEvidenceSourceTreeState || + manifest.releaseQualification != 'NOT_CLAIMED' || + manifest.digests != expectedDigests) { + throw new GradleException( + "${manifestFile}: stale registry/topology/source/digest metadata") + } + String cardStem = redisReadinessTaskStems[cardId] + String expectedTag + String expectedTask + if (manifest.evidenceCategory == null) { + expectedTag = "card-${cardId}" + expectedTask = + ":adapter:outbound:cache-redis:redis${cardStem}CapabilityTest" + } else { + String evidence = manifest.evidenceCategory as String + String evidenceStem = redisEvidenceTaskStems[evidence] + expectedTag = "card-${cardId} & redis-${evidence}" + expectedTask = + ":adapter:outbound:cache-redis:redis${cardStem}${evidenceStem}EvidenceTest" + } + if (manifest.tagExpression != expectedTag || manifest.taskPath != expectedTask) { + throw new GradleException( + "${manifestFile}: wrong task path or exact tag intersection") + } + File capabilityFile = new File(manifestFile.parentFile, 'capability-card.json') + File timelineFile = new File( + manifestFile.parentFile, 'topology-fault-timeline.json') + if (!capabilityFile.isFile() || !timelineFile.isFile()) { + throw new GradleException( + "${manifestFile}: missing sanitized evidence companions") + } + Map companionSha = manifest.companionSha256 as Map + if (companionSha?.keySet() != [ + 'capabilityCardSha256', + 'timelineSha256' + ] as Set || + companionSha.capabilityCardSha256 != + redisSha256(capabilityFile).substring('sha256:'.length()) || + companionSha.timelineSha256 != + redisSha256(timelineFile).substring('sha256:'.length())) { + throw new GradleException( + "${manifestFile}: companion checksum mismatch") + } + Map capability = new JsonSlurper().parse( + capabilityFile) as Map + Map timeline = new JsonSlurper().parse( + timelineFile) as Map + Map metadata = redisCapabilityMetadata[cardId] + if (capability.keySet() != capabilityFields || + capability.schemaVersion != 1 || + capability.cardId != cardId || + capability.readiness != 'selected' || + capability.releaseQualification != 'NOT_CLAIMED' || + capability.promotionTopology != card.selectedTopology || + capability.sourceRevision != redisEvidenceSourceRevision || + capability.sourceTreeState != redisEvidenceSourceTreeState || + capability.digests != expectedDigests || + capability.minimumRedisVersion != '7.2' || + capability.providerIds != metadata.providerIds || + capability.roles != metadata.roles || + capability.programIds != metadata.programs || + capability.keyVersions != metadata.keyVersions || + capability.codecVersions != metadata.codecVersions || + capability.guarantees != metadata.guarantees || + capability.nonGuarantees != metadata.nonGuarantees || + capability.requiredSettings != metadata.requiredSettings || + capability.evidenceProfile != card.requiredEvidence) { + throw new GradleException( + "${capabilityFile}: malformed or stale generated capability card") + } + if (timeline.keySet() != timelineFields || + timeline.schemaVersion != 1 || + timeline.taskName != manifest.taskPath.tokenize(':').last() || + timeline.cardId != cardId || + timeline.topology != card.selectedTopology || + timeline.evidence != manifest.evidenceCategory || + timeline.sourceRevision != redisEvidenceSourceRevision || + timeline.sourceTreeState != redisEvidenceSourceTreeState || + timeline.digests != expectedDigests || + !(timeline.events instanceof List)) { + throw new GradleException( + "${timelineFile}: malformed or stale sanitized timeline") + } + if (manifest.evidenceCategory != null && + (manifest.runtimeImageAttestation != 'CAPTURED' || + manifest.actualEventTimeline != 'CAPTURED' || + timeline.actualEventTimeline != 'CAPTURED' || + manifest.sourceTreeState != 'CLEAN')) { + throw new GradleException( + "${manifestFile}: selected promotion is blocked until actual-used image attestation and actual event timeline are captured") + } + } + Set capabilityCardDigests = cardManifests.collect { + Map companion = it.companionSha256 as Map + companion.capabilityCardSha256 as String + } as Set + if (capabilityCardDigests.size() != 1) { + throw new GradleException( + "${cardId}: capability card must be byte-identical across all evidence tasks") + } + } + Set unknownSelectedCards = manifests.findAll { + it.cardId != null + }.collect { it.cardId as String }.findAll { + !selectedCards.containsKey(it) + } as Set + if (!unknownSelectedCards.isEmpty()) { + throw new GradleException( + "Downloaded selected evidence contains unselected cards: ${unknownSelectedCards}") + } + int expectedManifestCount = selectedCards.collect { String ignored, Map card -> + Set resolved = new LinkedHashSet<>(card.requiredEvidence as List) + if (resolved.remove('selected-topology')) { + resolved.add(card.selectedTopology as String) + } + 1 + resolved.size() + }.sum() as int + if (manifests.size() != expectedManifestCount) { + throw new GradleException( + "Downloaded selected evidence manifest count mismatch; expected=${expectedManifestCount}, actual=${manifests.size()}") + } + logger.lifecycle( + "verifyRedisSelectedEvidenceArtifacts: reconciled selected cards ${selectedCards.keySet()}") + } +} + +redisPublicReadinessTasks.each { String cardId, String taskName -> + Map card = redisReadinessCards[cardId] + tasks.register(taskName) { + group = 'redis verification' + description = "Qualifies checked-in Redis capability card ${cardId}." + if (card.state != 'not-implemented') { + String cardStem = redisReadinessTaskStems[cardId] + dependsOn project(':adapter:outbound:cache-redis').tasks.named( + "redis${cardStem}CapabilityTest") + Set evidence = new LinkedHashSet<>(card.requiredEvidence as List) + if (evidence.remove('selected-topology')) { + evidence.add(card.selectedTopology as String) + } + evidence.each { String category -> + String evidenceStem = redisEvidenceTaskStems[category] + if (evidenceStem == null) { + throw new GradleException( + "Redis readiness card ${cardId} has unknown evidence ${category}") + } + dependsOn project(':adapter:outbound:cache-redis').tasks.named( + "redis${cardStem}${evidenceStem}EvidenceTest") + } + } + doLast { + if (card.state == 'not-implemented') { + logger.lifecycle("${cardId}: not selected (state=not-implemented)") + } else { + logger.lifecycle( + "${cardId}: ${card.state} evidence passed for topology ${card.selectedTopology}") + } + } + } +} + +tasks.register('redisProductionReadiness') { + group = 'redis verification' + description = 'Runs the checked-in selected Redis card release gate and architecture contracts.' + dependsOn project(':application-core').tasks.named('redisPolicyContractTest') + dependsOn project(':shared-contract').tasks.named('edgeRateLimitContractTest') + dependsOn project(':app-bootstrap').tasks.named('redisCompositionTest') + dependsOn tasks.named('verifyCleanArchitectureDependencies') + dependsOn tasks.named('verifyEnvKeys') + dependsOn tasks.named('verifyPublicPathSnapshot') + dependsOn tasks.named('verifyConfigurationPropertiesProcessor') + dependsOn verifyRedisSelectedEvidenceArtifacts + redisReadinessCards.findAll { ignored, card -> card.state == 'selected' }.each { + String cardId, Map ignored -> + dependsOn tasks.named(redisPublicReadinessTasks[cardId]) + } + doLast { + List selected = redisReadinessCards.findAll { + ignored, card -> card.state == 'selected' + }.keySet().toList() + if (selected.isEmpty()) { + logger.lifecycle( + 'redisProductionReadiness: no selected card; verified provider-disabled composition and no release R2 claim.') + } else { + logger.lifecycle("redisProductionReadiness: selected cards passed ${selected}") + } + } +} + +tasks.register('redisAllImplementedCandidates') { + group = 'redis verification' + description = 'Runs selected and implemented-candidate Redis cards without changing release labels.' + redisReadinessCards.findAll { + ignored, card -> card.state in ['selected', 'implemented-candidate'] + }.each { String cardId, Map ignored -> + dependsOn tasks.named(redisPublicReadinessTasks[cardId]) + } +} + def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') { group = 'verification' description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.' @@ -807,7 +2184,7 @@ tasks.register('verifyOneTypePerFile') { // Rationale in README.md. tasks.register('verifyEnvKeys') { group = 'verification' - description = 'Verifies src/.env covers application.yml placeholders and every APP_ key is registered.' + description = 'Verifies application.yml APP_ references, src/.env, and env-keys.yaml stay registered.' File envFile = file("${rootProject.projectDir}/.env") File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml") @@ -842,6 +2219,14 @@ tasks.register('verifyEnvKeys') { requiredPlaceholders << pm.group(1) } } + Set environmentSecretReferences = new TreeSet<>() + def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/) + while (sm.find()) { + environmentSecretReferences << sm.group(1) + } + Set applicationAppReferences = new TreeSet<>( + allPlaceholders.findAll { it.startsWith('APP_') }) + applicationAppReferences.addAll(environmentSecretReferences) // A. Every required (no inline default) placeholder must exist in .env. Set missingKeys = new TreeSet<>(requiredPlaceholders - envKeys) @@ -851,7 +2236,9 @@ tasks.register('verifyEnvKeys') { } // B. Every .env key must be referenced by some application.yml placeholder. - Set orphanedKeys = new TreeSet<>(envKeys - allPlaceholders) + Set knownApplicationReferences = new TreeSet<>(allPlaceholders) + knownApplicationReferences.addAll(environmentSecretReferences) + Set orphanedKeys = new TreeSet<>(envKeys - knownApplicationReferences) if (!orphanedKeys.isEmpty()) { throw new GradleException( "verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses: ${orphanedKeys}") @@ -873,9 +2260,20 @@ tasks.register('verifyEnvKeys') { "(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}") } + // D. Every application-owned reference is registered, including optional placeholders + // with inline defaults and literal secret://environment/APP_* references. + Set unregisteredApplicationReferences = + new TreeSet<>(applicationAppReferences - registryAppKeys) + if (!unregisteredApplicationReferences.isEmpty()) { + throw new GradleException( + "verifyEnvKeys: application.yml references APP_ keys absent from " + + "docs/registries/env-keys.yaml (optional defaults and environment " + + "secret references are included): ${unregisteredApplicationReferences}") + } + logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " + "${requiredPlaceholders.size()} required placeholders covered, " + - "${envAppKeys.size()} APP_ keys registered.") + "${applicationAppReferences.size()} application APP_ references registered.") } } diff --git a/src/config/architecture/modules.json b/src/config/architecture/modules.json index 3575ada..fe41a5e 100644 --- a/src/config/architecture/modules.json +++ b/src/config/architecture/modules.json @@ -173,6 +173,7 @@ "adapter-outbound-messaging", "adapter-outbound-cache-redis", "adapter-outbound-notification", + "adapter-outbound-fileserver", "adapter-outbound-httpclient", "adapter-outbound-identifier", "adapter-inbound-web", diff --git a/src/config/redis/program-set.schema.json b/src/config/redis/program-set.schema.json new file mode 100644 index 0000000..c6ec5bf --- /dev/null +++ b/src/config/redis/program-set.schema.json @@ -0,0 +1,169 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://caskeleton.dev/schemas/redis/program-set.schema.json", + "title": "Canonical Redis atomic program set", + "type": "object", + "required": [ + "schemaVersion", + "programSet", + "semanticRevision", + "minimumRedisVersion", + "resultSchemaVersion", + "readiness", + "programs" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "programSet": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{2,127}$" }, + "semanticRevision": { "type": "integer", "minimum": 1 }, + "minimumRedisVersion": { "type": "string", "pattern": "^[1-9][0-9]*\\.[0-9]+$" }, + "resultSchemaVersion": { "type": "integer", "minimum": 1 }, + "readiness": { "type": "string", "minLength": 1 }, + "applicationContractVersion": { "type": "integer", "minimum": 1 }, + "exactlyOnceScope": { "type": "string", "minLength": 1 }, + "guarantee": { "type": "string", "minLength": 1 }, + "fencing": { "type": "boolean" }, + "role": { "type": "string", "minLength": 1 }, + "clusterSupport": { "type": "string", "minLength": 1 }, + "semanticProviders": { "type": "object" }, + "programs": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/program" } + } + }, + "additionalProperties": false, + "$defs": { + "input": { + "type": "object", + "required": ["index", "name", "type", "maximumBytes"], + "properties": { + "index": { "type": "integer", "minimum": 1, "maximum": 64 }, + "name": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]{1,63}$" }, + "type": { "type": "string", "minLength": 1 }, + "maximumBytes": { "type": "integer", "minimum": 1, "maximum": 16778272 }, + "sameSlotGroup": { "type": "string" } + }, + "additionalProperties": false + }, + "resultSchema": { + "type": "object", + "required": ["version", "fieldCount", "maximumFieldBytes", "orderedFields"], + "properties": { + "version": { "type": "integer", "minimum": 1 }, + "fieldCount": { "type": "integer", "minimum": 1, "maximum": 16 }, + "maximumFieldBytes": { "type": "integer", "minimum": 1 }, + "orderedFields": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]{1,63}$" } + } + }, + "additionalProperties": false + }, + "state": { + "type": "object", + "required": ["type", "maximumBytes", "maximumEntries"], + "properties": { + "type": { "type": "string", "minLength": 1 }, + "maximumBytes": { "type": "integer", "minimum": 0, "maximum": 16777216 }, + "maximumEntries": { "type": "integer", "minimum": 0, "maximum": 4096 } + }, + "additionalProperties": false + }, + "ttl": { + "type": "object", + "required": ["mode", "minimumMillis", "maximumMillis"], + "properties": { + "mode": { "type": "string", "minLength": 1 }, + "minimumMillis": { "type": "integer", "minimum": 0 }, + "maximumMillis": { "type": "integer", "minimum": 0, "maximum": 2678400000 } + }, + "additionalProperties": false + }, + "program": { + "type": "object", + "required": [ + "id", + "semanticVersion", + "libraryName", + "registeredFunctionName", + "scriptResource", + "sha256", + "keyCount", + "argumentCount", + "replyFieldCount", + "keys", + "arguments", + "resultSchema", + "slotRule", + "state", + "ttl", + "validateBeforeFirstWrite", + "statuses", + "complexity", + "maximumIterations", + "stateGrowth", + "clock", + "minimumRedisVersion", + "retrySafety", + "timeoutCertainty", + "aclCommands" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]+-v[1-9][0-9]*$" }, + "semanticVersion": { "type": "string", "pattern": "^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$" }, + "libraryName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{2,63}$" }, + "registeredFunctionName": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{2,127}$" + }, + "scriptResource": { "type": "string", "pattern": "^redis/scripts/[a-z0-9-]+\\.lua$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "keyCount": { "type": "integer", "minimum": 1, "maximum": 64 }, + "argumentCount": { "type": "integer", "minimum": 1, "maximum": 64 }, + "replyFieldCount": { "type": "integer", "minimum": 1, "maximum": 16 }, + "keys": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/input" } }, + "arguments": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/input" } + }, + "resultSchema": { "$ref": "#/$defs/resultSchema" }, + "slotRule": { + "enum": ["SINGLE_KEY", "SAME_RESOURCE_HASH_TAG", "CROSS_SLOT_UNSUPPORTED"] + }, + "state": { "$ref": "#/$defs/state" }, + "ttl": { "$ref": "#/$defs/ttl" }, + "validateBeforeFirstWrite": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "statuses": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" } + }, + "complexity": { "type": "string", "minLength": 1 }, + "maximumIterations": { "type": "integer", "minimum": 0, "maximum": 4096 }, + "stateGrowth": { "type": "string", "minLength": 1 }, + "clock": { "type": "string", "minLength": 1 }, + "minimumRedisVersion": { + "type": "string", + "pattern": "^[1-9][0-9]*\\.[0-9]+$" + }, + "retrySafety": { "type": "string", "minLength": 1 }, + "timeoutCertainty": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" }, + "aclCommands": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9]*$" } + } + }, + "additionalProperties": false + } + } +} diff --git a/src/config/redis/readiness-cards.yaml b/src/config/redis/readiness-cards.yaml new file mode 100644 index 0000000..6782471 --- /dev/null +++ b/src/config/redis/readiness-cards.yaml @@ -0,0 +1,48 @@ +cards: + redis-cache: + state: implemented-candidate + selected-topology: standalone + required-evidence: + - standalone + - security + - fault + - compatibility + - selected-topology + redis-edge-rate-limit: + state: implemented-candidate + selected-topology: standalone + required-evidence: + - standalone + - security + - fault + - compatibility + - selected-topology + redis-request-replay-idempotency: + state: implemented-candidate + selected-topology: standalone + required-evidence: + - standalone + - security + - fault + - compatibility + - selected-topology + redis-cache-refresh-soft-lease: + state: implemented-candidate + selected-topology: standalone + required-evidence: + - standalone + - security + - fault + - compatibility + - selected-topology + redis-fenced-coordination: + state: not-implemented + redis-session: + state: implemented-candidate + selected-topology: standalone + required-evidence: + - standalone + - security + - fault + - compatibility + - selected-topology diff --git a/src/gradle/redis-test-images.properties b/src/gradle/redis-test-images.properties new file mode 100644 index 0000000..9a6121d --- /dev/null +++ b/src/gradle/redis-test-images.properties @@ -0,0 +1,5 @@ +redis.minimum.image=redis:7.2.14-alpine@sha256:dfa18828cbc07b3ae6a95ec7343f6c214fdee2d836197b4be8e9904420762cd8 +redis.below-minimum.image=redis:7.0.15-alpine@sha256:c9d92d840fd011c908f040592857c724ae6d877f2aba5c40ad963276507386b2 +redis.next-minor.image=redis:7.4.9-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 +redis.approved.image=redis:7.4.9-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 +toxiproxy.image=ghcr.io/shopify/toxiproxy:2.12.0@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile index 61fe4e8..2e81420 100644 --- a/src/sample-portfolio/gradle.lockfile +++ b/src/sample-portfolio/gradle.lockfile @@ -278,6 +278,7 @@ org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath, org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/sample-portfolio/src/main/resources/application.yml b/src/sample-portfolio/src/main/resources/application.yml index 8910d12..5bda0a8 100644 --- a/src/sample-portfolio/src/main/resources/application.yml +++ b/src/sample-portfolio/src/main/resources/application.yml @@ -144,6 +144,32 @@ logging: # These are bound by @ConfigurationProperties in the respective modules. # --------------------------------------------------------------------------- ca-skeleton: + capabilities: + cache: + bindings: + default: ${APP_CACHE_CANONICAL_DEFAULT_PROVIDER:disabled} + regions: + default: + key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET + namespace-application: ${APP_NAME:sample-portfolio} + namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} + semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} + hash-key-version: 1 + key-version: 1 + policy-revision: canonical-default-r1 + positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:240s} + positive-hard-ttl: ${APP_CACHE_DEFAULT_TTL:300s} + negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} + ttl-jitter: ${APP_CACHE_REDIS_TTL_JITTER:0.10} + maximum-value-bytes: 61440 + l1: + enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} + maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} + maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} + maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} + time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} + generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} + invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} bootstrap: app-name: ${APP_NAME:sample-portfolio} runtime: @@ -156,10 +182,11 @@ ca-skeleton: presentation: api-base-path: ${PRESENTATION_API_BASE_PATH:/api} rate-limit: - enabled: ${APP_RATE_LIMIT_ENABLED:true} - limit: 100 - window: 1s - algorithm: fixed-window + enabled: ${APP_RATE_LIMIT_ENABLED:false} + default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default} + hash-key-version: ${APP_RATE_LIMIT_HASH_KEY_VERSION:1} + caller-deadline-budget: 2s + client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only} idempotency: ttl: ${APP_IDEMPOTENCY_TTL:24h} reaper-interval: 10m @@ -232,6 +259,14 @@ app: namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} maximum-value-bytes: ${APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES:1048576} + l1: + enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} + maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} + maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} + maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} + time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} + generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} + invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} messaging: broker: ${APP_MESSAGING_BROKER:} kafka: @@ -241,22 +276,3 @@ app: provider: ${APP_NOTIFICATION_SLACK_PROVIDER:} email: provider: ${APP_NOTIFICATION_EMAIL_PROVIDER:} - outbound: - http: - connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT:2s} - read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT:5s} - global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT:10s} - maximum-in-flight-calls: ${APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS:128} - retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} - retry: - max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} - initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms} - backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0} - circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false} - circuit-breaker: - failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50} - sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100} - minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100} - wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s} - permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10} - response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java index df0b2dd..8b35113 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java @@ -106,6 +106,11 @@ class CreateWorkLogOutboxTest { return result; } + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + @Override public T inRead(Supplier action) { return action.get(); @@ -269,6 +274,11 @@ class CreateWorkLogOutboxTest { return a.get(); } + @Override + public T inRootWrite(Supplier a) { + return a.get(); + } + public T inRead(Supplier a) { return a.get(); } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java index cb6a566..aa20e9e 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java @@ -23,6 +23,11 @@ class ListRecentWorkLogSummariesUseCaseTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { inReadCalled = true; diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java index a132016..878d327 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java @@ -80,6 +80,11 @@ class WorkLogUseCasesTest { return a.get(); } + @Override + public T inRootWrite(Supplier a) { + return a.get(); + } + public T inRead(Supplier a) { return a.get(); } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java index 20b7b6c..9a515fd 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java @@ -178,6 +178,11 @@ class WorkLogAuthorizationContractTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { return action.get(); diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java index e796c75..00d40ba 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java @@ -156,6 +156,11 @@ class WorkLogAuthorizationE2ETest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { return action.get(); diff --git a/src/sample-portfolio/src/test/resources/application-test.yml b/src/sample-portfolio/src/test/resources/application-test.yml index 1e51a66..1bacda0 100644 --- a/src/sample-portfolio/src/test/resources/application-test.yml +++ b/src/sample-portfolio/src/test/resources/application-test.yml @@ -180,21 +180,3 @@ app: provider: "" email: provider: "" - outbound: - http: - connect-timeout: 2s - read-timeout: 5s - global-call-timeout: 10s - retry-enabled: false - retry: - max-attempts: 3 - initial-backoff: 100ms - backoff-multiplier: 2.0 - circuit-breaker-enabled: false - circuit-breaker: - failure-rate-threshold: 50 - sliding-window-size: 100 - minimum-number-of-calls: 100 - wait-duration-in-open-state: 60s - permitted-calls-in-half-open: 10 - response-size-limit: 10MB diff --git a/src/shared-contract/CLAUDE.md b/src/shared-contract/CLAUDE.md index a7330a6..1f1ddcf 100644 --- a/src/shared-contract/CLAUDE.md +++ b/src/shared-contract/CLAUDE.md @@ -19,6 +19,8 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat - Error code contract (`error/ApiErrorCode` interface, `error/OperationalError` enum). - Cross-cutting mapper sentinel (`error/MappingException`). - Request value contract (`request/Patch`). +- Framework-neutral Redis operational snapshot (`health/RedisHealthSnapshotProvider`) used by + adapters and bootstrap health composition without leaking Actuator or native client types. - Tracing contract types (`tracing/TraceParent`, `tracing/BaggageAllowlist`, `tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist, and the span-error-recording seam (feature-distributed-tracing-contract; the OTel diff --git a/src/shared-contract/README.md b/src/shared-contract/README.md index 4409d68..59eb6aa 100644 --- a/src/shared-contract/README.md +++ b/src/shared-contract/README.md @@ -12,6 +12,25 @@ Micrometer·OTel 같은 프레임워크 타입을 import 하지 않는다. 그 persistence·outbound)이 프레임워크 충돌 없이 이 타입들을 공유할 수 있고, HTTP status 같은 전송 개념도 프레임워크 타입이 아니라 평범한 `int` 로 표현한다(매핑은 web 어댑터가 한다). +`health/RedisHealthSnapshotProvider`도 같은 원칙을 따른다. Redis adapter는 native client나 +예외를 노출하지 않고 bounded role/capability 상태만 제공하며, bootstrap이 이를 Actuator +health로 변환한다. + +--- + +## ratelimit — edge enforcement 계약 + +`EdgeRateLimitPort`는 inbound와 Redis adapter 사이의 provider-neutral edge enforcement +경계다. Business quota나 entitlement policy를 담는 application use case가 아니며, 이미 +pseudonymized된 subject digest와 bounded policy ID/cost/deadline만 받는다. + +`RateLimitPolicy`는 fixed window, sliding-window counter, token bucket 중 하나의 exact parameter +subtype을 고정하고 Lua의 `2^53-1` 안전 정수, 1일 window/refill/cleanup, 1시간 clock regression, +`FAIL_CLOSED`만 허용한다. 결과는 evaluated, known pre-send unavailable, post-dispatch +indeterminate, schema/program/reply incompatible로 분리하므로 adapter가 장애를 allow나 평범한 +deny로 숨길 수 없다. 이 패키지는 Java 표준 라이브러리만 사용하며 Redis key, command, Lua, +Spring/Lettuce 타입을 노출하지 않는다. + --- ## error — 에러 코드 계약 diff --git a/src/shared-contract/build.gradle b/src/shared-contract/build.gradle index 8814dec..d1cdf68 100644 --- a/src/shared-contract/build.gradle +++ b/src/shared-contract/build.gradle @@ -1,3 +1,29 @@ // Skeleton-wide operational contracts only. No business/domain concepts. dependencies { } + +sourceSets { + edgeRateLimitContractTest { + java.srcDir 'src/edgeRateLimitContractTest/java' + resources.srcDir 'src/edgeRateLimitContractTest/resources' + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +configurations { + edgeRateLimitContractTestImplementation.extendsFrom testImplementation + edgeRateLimitContractTestCompileOnly.extendsFrom testCompileOnly + edgeRateLimitContractTestRuntimeOnly.extendsFrom testRuntimeOnly +} + +tasks.register('edgeRateLimitContractTest', Test) { + group = 'redis verification' + description = 'Runs the provider-neutral edge rate-limit shared contract.' + testClassesDirs = sourceSets.edgeRateLimitContractTest.output.classesDirs + classpath = sourceSets.edgeRateLimitContractTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} diff --git a/src/shared-contract/gradle.lockfile b/src/shared-contract/gradle.lockfile index ff6d49b..ef9b4ad 100644 --- a/src/shared-contract/gradle.lockfile +++ b/src/shared-contract/gradle.lockfile @@ -1,40 +1,40 @@ # 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. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.puppycrawl.tools:checkstyle:13.5.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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 @@ -50,31 +50,31 @@ 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.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=edgeRateLimitContractTestCompileClasspath,testCompileClasspath +org.assertj:assertj-core:3.27.6=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,edgeRateLimitContractTestAnnotationProcessor,edgeRateLimitContractTestCompileClasspath,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.pcollections:pcollections:4.0.1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j diff --git a/src/shared-contract/src/edgeRateLimitContractTest/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitProviderNeutralContractTest.java b/src/shared-contract/src/edgeRateLimitContractTest/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitProviderNeutralContractTest.java new file mode 100644 index 0000000..5001dce --- /dev/null +++ b/src/shared-contract/src/edgeRateLimitContractTest/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitProviderNeutralContractTest.java @@ -0,0 +1,75 @@ +package dev.caskeleton.shared.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class EdgeRateLimitProviderNeutralContractTest { + + @Test + void allPortableAlgorithmsHaveBoundedProviderNeutralPolicies() { + List policies = + List.of( + policy( + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(100, Duration.ofMinutes(1))), + policy( + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(100, Duration.ofMinutes(1))), + policy( + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(100, 10, Duration.ofSeconds(1)))); + + assertThat(policies) + .extracting(RateLimitPolicy::algorithm) + .containsExactlyElementsOf(List.of(RateLimitAlgorithm.values())); + assertThat(policies) + .allSatisfy( + policy -> { + assertThat(policy.failurePolicy()).isEqualTo(RateLimitFailurePolicy.FAIL_CLOSED); + assertThat(policy.evaluationDedupPolicy().maximumEntries()) + .isLessThanOrEqualTo(RateLimitEvaluationDedupPolicy.MAXIMUM_ENTRIES); + }); + } + + @Test + void requestAcceptsOnlyPseudonymousSubjectAndBoundedEvaluationIdentity() { + RateLimitSubjectDigest digest = new RateLimitSubjectDigest("v1:" + "a".repeat(64)); + + RateLimitRequest request = + new RateLimitRequest( + "edge-default", + digest, + 1, + "ev1:AAAAAAAAAAAAAAAAAAAAAA", + Instant.parse("2026-07-29T00:00:01Z")); + + assertThat(request.subjectDigest()).isEqualTo(digest.value()); + assertThatThrownBy( + () -> + new RateLimitRequest( + "edge-default", + "raw-user@example.com", + 1, + "", + Instant.parse("2026-07-29T00:00:01Z"))) + .isInstanceOf(IllegalArgumentException.class); + } + + private static RateLimitPolicy policy(RateLimitAlgorithm algorithm, RateParameters parameters) { + return new RateLimitPolicy( + "edge-default", + "policy-v1", + algorithm, + parameters, + 10, + Duration.ofSeconds(5), + Duration.ofSeconds(1), + RateLimitFailurePolicy.FAIL_CLOSED, + RateLimitEvaluationDedupPolicy.enabledDefaults()); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java new file mode 100644 index 0000000..d43fcff --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java @@ -0,0 +1,103 @@ +package dev.caskeleton.shared.health; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Framework-neutral operational snapshot for canonically bound Redis roles. + * + *

The Redis adapter implements this contract; the bootstrap composition root maps it to its + * health framework. No command client, native connection, credential, or secret material crosses + * this boundary. + */ +public interface RedisHealthSnapshotProvider { + + Snapshot snapshot(); + + record Snapshot(Instant observedAt, List roles) { + + public Snapshot { + Objects.requireNonNull(observedAt, "observedAt must be non-null"); + roles = List.copyOf(Objects.requireNonNull(roles, "roles must be non-null")); + } + } + + record RoleHealth( + Role role, + String deploymentId, + boolean required, + EvictionPolicy expectedEviction, + EvictionAttestation evictionAttestation, + Set capabilities, + State state, + Reason reason, + Instant semanticObservedAt, + long semanticAgeMillis, + boolean semanticStale) { + + public RoleHealth { + Objects.requireNonNull(role, "role must be non-null"); + Objects.requireNonNull(deploymentId, "deploymentId must be non-null"); + Objects.requireNonNull(expectedEviction, "expectedEviction must be non-null"); + Objects.requireNonNull(evictionAttestation, "evictionAttestation must be non-null"); + capabilities = + Set.copyOf(Objects.requireNonNull(capabilities, "capabilities must be non-null")); + Objects.requireNonNull(state, "state must be non-null"); + Objects.requireNonNull(reason, "reason must be non-null"); + Objects.requireNonNull(semanticObservedAt, "semanticObservedAt must be non-null"); + if (semanticAgeMillis < 0) { + throw new IllegalArgumentException("semanticAgeMillis must be non-negative"); + } + } + } + + enum Role { + CACHE, + COORDINATION, + SESSION + } + + enum Capability { + CACHE, + RATE_LIMIT, + IDEMPOTENCY, + EFFICIENCY_LEASE, + SESSION + } + + enum State { + AVAILABLE, + UNAVAILABLE, + OVERLOADED + } + + enum Reason { + COMMAND_UNAVAILABLE, + ROUTE_CLOSED, + SEMANTIC_PROBE_SUCCEEDED, + SEMANTIC_READ_WRITE_FAILED, + SEMANTIC_PROGRAM_ACL_DENIED, + SEMANTIC_PROGRAM_FAILED, + SERVER_VERSION_UNSUPPORTED, + SEMANTIC_PROBE_IN_PROGRESS, + SEMANTIC_OBSERVATION_STALE, + COMMAND_SATURATED, + RECENT_COMMAND_FAILURE + } + + enum EvictionPolicy { + ALLKEYS_LFU, + ALLKEYS_LRU, + NOEVICTION + } + + /** + * The runtime never requests Redis CONFIG access. The configured expectation therefore needs an + * external conformance check against the server's effective maxmemory-policy. + */ + enum EvictionAttestation { + CONFIGURED_EXPECTATION_ONLY + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitPort.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitPort.java new file mode 100644 index 0000000..62615af --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitPort.java @@ -0,0 +1,12 @@ +package dev.caskeleton.shared.ratelimit; + +/** + * Provider-neutral edge-enforcement boundary. + * + *

Business quota policy belongs to an application use case, not this technical edge port. + */ +@FunctionalInterface +public interface EdgeRateLimitPort { + + RateLimitOutcome evaluate(RateLimitRequest request); +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java new file mode 100644 index 0000000..c11675b --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java @@ -0,0 +1,43 @@ +package dev.caskeleton.shared.ratelimit; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Bounded canonical input to an {@link EdgeSubjectPseudonymizer}. + * + *

The identity is intentionally raw only at this short-lived pre-pseudonymization boundary. It + * must never be copied into a {@link RateLimitRequest}, provider key, log field, metric tag, or + * error response. + */ +public record EdgeRateLimitSubject(Kind kind, String canonicalIdentity, String operationId) { + + private static final int MAXIMUM_IDENTITY_BYTES = 512; + private static final int MAXIMUM_OPERATION_BYTES = 256; + + public EdgeRateLimitSubject { + Objects.requireNonNull(kind, "kind must not be null"); + canonicalIdentity = boundedText(canonicalIdentity, "canonicalIdentity", MAXIMUM_IDENTITY_BYTES); + operationId = boundedText(operationId, "operationId", MAXIMUM_OPERATION_BYTES); + } + + private static String boundedText(String value, String field, int maximumBytes) { + Objects.requireNonNull(value, field + " must not be null"); + if (value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + if (value.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " must not contain control characters"); + } + if (value.getBytes(StandardCharsets.UTF_8).length > maximumBytes) { + throw new IllegalArgumentException(field + " exceeds " + maximumBytes + " UTF-8 bytes"); + } + return value; + } + + public enum Kind { + PRINCIPAL, + API_KEY, + CLIENT_IP + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeSubjectPseudonymizer.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeSubjectPseudonymizer.java new file mode 100644 index 0000000..b650b64 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeSubjectPseudonymizer.java @@ -0,0 +1,13 @@ +package dev.caskeleton.shared.ratelimit; + +/** + * Converts a bounded canonical edge subject into a stable, versioned HMAC digest. + * + *

Implementations own secret access and rotation. Inbound adapters must not resolve or retain + * the HMAC secret themselves. + */ +@FunctionalInterface +public interface EdgeSubjectPseudonymizer { + + RateLimitSubjectDigest pseudonymize(EdgeRateLimitSubject subject); +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitAlgorithm.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitAlgorithm.java new file mode 100644 index 0000000..a2faaf1 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitAlgorithm.java @@ -0,0 +1,7 @@ +package dev.caskeleton.shared.ratelimit; + +public enum RateLimitAlgorithm { + FIXED_WINDOW, + SLIDING_COUNTER, + TOKEN_BUCKET +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitBounds.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitBounds.java new file mode 100644 index 0000000..c636455 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitBounds.java @@ -0,0 +1,84 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Duration; +import java.util.Objects; +import java.util.regex.Pattern; + +final class RateLimitBounds { + + static final Duration MAXIMUM_WINDOW = Duration.ofDays(1); + static final Duration MAXIMUM_CLEANUP_GRACE = Duration.ofDays(1); + static final Duration MAXIMUM_CLOCK_REGRESSION = Duration.ofHours(1); + static final Duration MAXIMUM_RETRY_AFTER = + Duration.ofMillis(RateParameters.MAXIMUM_EXACT_INTEGER); + + private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}"); + private static final Pattern POLICY_REVISION = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Pattern SUBJECT_DIGEST = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:-]{15,127}"); + private static final Pattern EVALUATION_ID = + Pattern.compile("ev[1-9][0-9]{0,3}:[A-Za-z0-9_-]{22,64}"); + + private RateLimitBounds() {} + + static String policyId(String value) { + return boundedIdentifier(value, "policyId", POLICY_ID); + } + + static String policyRevision(String value) { + return boundedIdentifier(value, "policyRevision", POLICY_REVISION); + } + + static String subjectDigest(String value) { + return boundedIdentifier(value, "subjectDigest", SUBJECT_DIGEST); + } + + static String optionalEvaluationId(String value) { + Objects.requireNonNull(value, "evaluationId must not be null"); + if (value.isEmpty()) { + return value; + } + return boundedIdentifier(value, "evaluationId", EVALUATION_ID); + } + + static Duration positiveDuration(Duration value, String field, Duration maximum) { + return duration(value, field, false, maximum); + } + + static Duration nonNegativeDuration(Duration value, String field, Duration maximum) { + return duration(value, field, true, maximum); + } + + private static String boundedIdentifier(String value, String field, Pattern pattern) { + Objects.requireNonNull(value, field + " must not be null"); + if (!pattern.matcher(value).matches()) { + throw new IllegalArgumentException(field + " has an invalid bounded representation"); + } + return value; + } + + private static Duration duration( + Duration value, String field, boolean zeroAllowed, Duration maximum) { + Objects.requireNonNull(value, field + " must not be null"); + Objects.requireNonNull(maximum, "maximum must not be null"); + if (value.isNegative() || (!zeroAllowed && value.isZero())) { + throw new IllegalArgumentException( + field + (zeroAllowed ? " must not be negative" : " must be positive")); + } + if (value.compareTo(maximum) > 0) { + throw new IllegalArgumentException(field + " exceeds its maximum duration of " + maximum); + } + + long milliseconds; + try { + milliseconds = value.toMillis(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + field + " exceeds the supported milliseconds range", exception); + } + if (!Duration.ofMillis(milliseconds).equals(value)) { + throw new IllegalArgumentException(field + " must use whole milliseconds"); + } + return value; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java new file mode 100644 index 0000000..7b657a2 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java @@ -0,0 +1,59 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +public record RateLimitDecision( + boolean allowed, + long limit, + long remaining, + Duration retryAfter, + Instant resetAt, + String policyId, + String policyRevision, + DecisionSource source, + DecisionCertainty certainty) { + + public RateLimitDecision { + if (limit <= 0 || limit > RateParameters.MAXIMUM_EXACT_INTEGER) { + throw new IllegalArgumentException( + "limit must be positive and within the exact integer range"); + } + if (remaining < 0 || remaining > limit) { + throw new IllegalArgumentException("remaining must be between zero and limit"); + } + RateLimitBounds.nonNegativeDuration( + retryAfter, "retryAfter", RateLimitBounds.MAXIMUM_RETRY_AFTER); + if (allowed && !retryAfter.isZero()) { + throw new IllegalArgumentException("retryAfter must be zero for an allowed decision"); + } + if (!allowed && retryAfter.isZero()) { + throw new IllegalArgumentException("retryAfter must be positive for a denied decision"); + } + Objects.requireNonNull(resetAt, "resetAt must not be null"); + try { + long resetAtMillis = resetAt.toEpochMilli(); + if (resetAtMillis < 0 || resetAtMillis > RateParameters.MAXIMUM_EXACT_INTEGER) { + throw new IllegalArgumentException( + "resetAt must be within the non-negative exact integer range"); + } + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "resetAt must be representable as epoch milliseconds", exception); + } + RateLimitBounds.policyId(policyId); + RateLimitBounds.policyRevision(policyRevision); + Objects.requireNonNull(source, "source must not be null"); + Objects.requireNonNull(certainty, "certainty must not be null"); + } + + public enum DecisionSource { + GLOBAL_REDIS + } + + public enum DecisionCertainty { + CERTAIN, + APPROXIMATE_ALGORITHM + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java new file mode 100644 index 0000000..9c171e8 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java @@ -0,0 +1,54 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Duration; +import java.util.Objects; + +/** + * Bounded response-loss replay policy for one rate-limit policy. + * + *

The byte limit covers the logical evaluation-ID and decision payload retained by the Redis + * program. Redis allocator overhead remains deployment/version evidence, not a portable byte + * contract. + */ +public record RateLimitEvaluationDedupPolicy( + boolean enabled, Duration timeToLive, int maximumEntries, int maximumStoredBytes) { + + public static final int MAXIMUM_EVALUATION_ID_BYTES = 71; + public static final int MAXIMUM_DECISION_BYTES = 128; + public static final int MAXIMUM_ENTRIES = 1024; + public static final int MAXIMUM_STORED_BYTES = 262_144; + public static final Duration MAXIMUM_TIME_TO_LIVE = Duration.ofMinutes(5); + + public RateLimitEvaluationDedupPolicy { + Objects.requireNonNull(timeToLive, "timeToLive must not be null"); + if (!enabled) { + if (!timeToLive.isZero() || maximumEntries != 0 || maximumStoredBytes != 0) { + throw new IllegalArgumentException( + "disabled evaluation dedup must not allocate TTL, entries, or stored bytes"); + } + } else { + RateLimitBounds.positiveDuration(timeToLive, "timeToLive", MAXIMUM_TIME_TO_LIVE); + if (maximumEntries < 1 || maximumEntries > MAXIMUM_ENTRIES) { + throw new IllegalArgumentException("maximumEntries must be in 1.." + MAXIMUM_ENTRIES); + } + if (maximumStoredBytes < 1 || maximumStoredBytes > MAXIMUM_STORED_BYTES) { + throw new IllegalArgumentException( + "maximumStoredBytes must be in 1.." + MAXIMUM_STORED_BYTES); + } + long requiredLogicalBytes = + (long) maximumEntries * (MAXIMUM_EVALUATION_ID_BYTES + MAXIMUM_DECISION_BYTES); + if (requiredLogicalBytes > maximumStoredBytes) { + throw new IllegalArgumentException( + "maximumStoredBytes cannot bound every configured evaluation entry"); + } + } + } + + public static RateLimitEvaluationDedupPolicy enabledDefaults() { + return new RateLimitEvaluationDedupPolicy(true, Duration.ofSeconds(5), 256, 65_536); + } + + public static RateLimitEvaluationDedupPolicy disabled() { + return new RateLimitEvaluationDedupPolicy(false, Duration.ZERO, 0, 0); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitFailurePolicy.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitFailurePolicy.java new file mode 100644 index 0000000..9fd155b --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitFailurePolicy.java @@ -0,0 +1,6 @@ +package dev.caskeleton.shared.ratelimit; + +/** V1 deliberately does not claim fail-open or local fallback semantics. */ +public enum RateLimitFailurePolicy { + FAIL_CLOSED +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java new file mode 100644 index 0000000..eb3e9f6 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java @@ -0,0 +1,59 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Duration; +import java.util.Objects; + +public sealed interface RateLimitOutcome + permits RateLimitOutcome.Evaluated, + RateLimitOutcome.Unavailable, + RateLimitOutcome.Indeterminate, + RateLimitOutcome.Incompatible { + + record Evaluated(RateLimitDecision decision) implements RateLimitOutcome { + + public Evaluated { + Objects.requireNonNull(decision, "decision must not be null"); + } + } + + record Unavailable(String policyId, Duration retryAfter, UnavailableCategory category) + implements RateLimitOutcome { + + public Unavailable { + RateLimitBounds.policyId(policyId); + RateLimitBounds.positiveDuration( + retryAfter, "retryAfter", RateLimitBounds.MAXIMUM_RETRY_AFTER); + Objects.requireNonNull(category, "category must not be null"); + } + } + + record Indeterminate(String policyId, Duration retryAfter) implements RateLimitOutcome { + + public Indeterminate { + RateLimitBounds.policyId(policyId); + RateLimitBounds.positiveDuration( + retryAfter, "retryAfter", RateLimitBounds.MAXIMUM_RETRY_AFTER); + } + } + + record Incompatible(String policyId, IncompatibleCategory category) implements RateLimitOutcome { + + public Incompatible { + RateLimitBounds.policyId(policyId); + Objects.requireNonNull(category, "category must not be null"); + } + } + + public enum UnavailableCategory { + ADMISSION_REJECTED, + UNAVAILABLE_BEFORE_SEND, + NO_MUTATION_CONFIRMED, + CLOCK_UNSAFE + } + + public enum IncompatibleCategory { + STATE_INCOMPATIBLE, + PROGRAM_INCOMPATIBLE, + REPLY_INCOMPATIBLE + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java new file mode 100644 index 0000000..57b7a94 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java @@ -0,0 +1,155 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Duration; +import java.util.Objects; + +public record RateLimitPolicy( + String policyId, + String policyRevision, + RateLimitAlgorithm algorithm, + RateParameters parameters, + long maximumCost, + Duration cleanupGrace, + Duration maximumClockRegression, + RateLimitFailurePolicy failurePolicy, + RateLimitEvaluationDedupPolicy evaluationDedupPolicy) { + + public RateLimitPolicy( + String policyId, + String policyRevision, + RateLimitAlgorithm algorithm, + RateParameters parameters, + long maximumCost, + Duration cleanupGrace, + Duration maximumClockRegression, + RateLimitFailurePolicy failurePolicy) { + this( + policyId, + policyRevision, + algorithm, + parameters, + maximumCost, + cleanupGrace, + maximumClockRegression, + failurePolicy, + RateLimitEvaluationDedupPolicy.enabledDefaults()); + } + + public RateLimitPolicy { + RateLimitBounds.policyId(policyId); + RateLimitBounds.policyRevision(policyRevision); + Objects.requireNonNull(algorithm, "algorithm must not be null"); + Objects.requireNonNull(parameters, "parameters must not be null"); + if (maximumCost <= 0 || maximumCost > RateParameters.MAXIMUM_EXACT_INTEGER) { + throw new IllegalArgumentException( + "maximumCost must be positive and within Lua's exact integer range"); + } + RateLimitBounds.positiveDuration( + cleanupGrace, "cleanupGrace", RateLimitBounds.MAXIMUM_CLEANUP_GRACE); + RateLimitBounds.nonNegativeDuration( + maximumClockRegression, "maximumClockRegression", RateLimitBounds.MAXIMUM_CLOCK_REGRESSION); + Objects.requireNonNull(failurePolicy, "failurePolicy must not be null"); + Objects.requireNonNull(evaluationDedupPolicy, "evaluationDedupPolicy must not be null"); + if (failurePolicy != RateLimitFailurePolicy.FAIL_CLOSED) { + throw new IllegalArgumentException("only FAIL_CLOSED is supported in v1"); + } + + switch (algorithm) { + case FIXED_WINDOW -> validateFixedWindow(parameters, maximumCost, cleanupGrace); + case SLIDING_COUNTER -> validateSlidingCounter(parameters, maximumCost, cleanupGrace); + case TOKEN_BUCKET -> validateTokenBucket(parameters, maximumCost, cleanupGrace); + default -> throw new IllegalArgumentException("unsupported rate-limit algorithm"); + } + } + + private static void validateFixedWindow( + RateParameters parameters, long maximumCost, Duration cleanupGrace) { + if (!(parameters instanceof RateParameters.FixedWindow fixedWindow)) { + throw algorithmMismatch(RateLimitAlgorithm.FIXED_WINDOW, parameters); + } + requireMaximumCost(maximumCost, fixedWindow.limit()); + RateParameters.exactSum( + "fixed-window consumed plus maximumCost", fixedWindow.limit(), maximumCost); + RateParameters.exactSum( + "fixed-window state TTL", fixedWindow.window().toMillis(), cleanupGrace.toMillis()); + } + + private static void validateSlidingCounter( + RateParameters parameters, long maximumCost, Duration cleanupGrace) { + if (!(parameters instanceof RateParameters.SlidingCounter slidingCounter)) { + throw algorithmMismatch(RateLimitAlgorithm.SLIDING_COUNTER, parameters); + } + requireMaximumCost(maximumCost, slidingCounter.limit()); + + long maximumUnscaledConsumption = + RateParameters.exactSum( + "sliding-counter previous/current consumption", + slidingCounter.limit(), + slidingCounter.limit(), + maximumCost); + RateParameters.exactProduct( + "sliding-counter weighted consumption", maximumUnscaledConsumption, RateParameters.SCALE); + RateParameters.exactProduct( + "sliding-counter window weight", slidingCounter.window().toMillis(), RateParameters.SCALE); + long twoWindows = + RateParameters.exactProduct( + "sliding-counter state TTL", slidingCounter.window().toMillis(), 2); + RateParameters.exactSum("sliding-counter state TTL", twoWindows, cleanupGrace.toMillis()); + } + + private static void validateTokenBucket( + RateParameters parameters, long maximumCost, Duration cleanupGrace) { + if (!(parameters instanceof RateParameters.TokenBucket tokenBucket)) { + throw algorithmMismatch(RateLimitAlgorithm.TOKEN_BUCKET, parameters); + } + requireMaximumCost(maximumCost, tokenBucket.capacity()); + if (tokenBucket.refillTokens() > tokenBucket.capacity()) { + throw new IllegalArgumentException("refillTokens must not exceed token-bucket capacity"); + } + + long scaledCapacity = + RateParameters.exactProduct( + "token-bucket capacity scale", tokenBucket.capacity(), RateParameters.SCALE); + long scaledMaximumCost = + RateParameters.exactProduct( + "token-bucket maximumCost scale", maximumCost, RateParameters.SCALE); + RateParameters.exactSum( + "token-bucket tokens plus maximumCost", scaledCapacity, scaledMaximumCost); + RateParameters.exactProduct( + "token-bucket refill time product", scaledCapacity, tokenBucket.refillPeriod().toMillis()); + + long scaledRefill = + RateParameters.exactProduct( + "token-bucket refill scale", tokenBucket.refillTokens(), RateParameters.SCALE); + long fullRefillPeriods = ceilingDivide(scaledCapacity, scaledRefill); + RateParameters.exactProduct( + "token-bucket full-refill horizon", + fullRefillPeriods, + tokenBucket.refillPeriod().toMillis()); + RateParameters.exactSum( + "token-bucket state TTL", + RateParameters.exactProduct( + "token-bucket state TTL", fullRefillPeriods, tokenBucket.refillPeriod().toMillis()), + cleanupGrace.toMillis()); + } + + private static void requireMaximumCost(long maximumCost, long algorithmLimit) { + if (maximumCost > algorithmLimit) { + throw new IllegalArgumentException( + "maximumCost must not exceed the algorithm limit or capacity"); + } + } + + private static long ceilingDivide(long dividend, long divisor) { + return dividend / divisor + (dividend % divisor == 0 ? 0 : 1); + } + + private static IllegalArgumentException algorithmMismatch( + RateLimitAlgorithm expected, RateParameters actual) { + return new IllegalArgumentException( + "algorithm " + + expected + + " does not match parameter type " + + actual.getClass().getSimpleName()); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java new file mode 100644 index 0000000..6b28aa3 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Instant; +import java.util.Objects; + +public record RateLimitRequest( + String policyId, String subjectDigest, long cost, String evaluationId, Instant callerDeadline) { + + public RateLimitRequest( + String policyId, + RateLimitSubjectDigest subject, + long cost, + String evaluationId, + Instant callerDeadline) { + this( + policyId, + Objects.requireNonNull(subject, "subject must not be null").value(), + cost, + evaluationId, + callerDeadline); + } + + public RateLimitRequest { + RateLimitBounds.policyId(policyId); + RateLimitBounds.subjectDigest(subjectDigest); + if (cost <= 0 || cost > RateParameters.MAXIMUM_EXACT_INTEGER) { + throw new IllegalArgumentException( + "cost must be positive and within the exact integer range"); + } + RateLimitBounds.optionalEvaluationId(evaluationId); + Objects.requireNonNull(callerDeadline, "callerDeadline must not be null"); + try { + long epochMillis = callerDeadline.toEpochMilli(); + if (Instant.ofEpochMilli(epochMillis).isAfter(callerDeadline)) { + throw new IllegalArgumentException( + "callerDeadline must have a non-forward epoch millisecond representation"); + } + } catch (ArithmeticException exception) { + throw new IllegalArgumentException( + "callerDeadline must be representable as epoch milliseconds", exception); + } + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitSubjectDigest.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitSubjectDigest.java new file mode 100644 index 0000000..23e4cd7 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitSubjectDigest.java @@ -0,0 +1,19 @@ +package dev.caskeleton.shared.ratelimit; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Versioned lowercase HMAC-SHA-256 representation safe to cross the provider port boundary. */ +public record RateLimitSubjectDigest(String value) { + + private static final Pattern VERSIONED_HMAC_SHA256 = + Pattern.compile("v[1-9][0-9]{0,3}:[0-9a-f]{64}"); + + public RateLimitSubjectDigest { + Objects.requireNonNull(value, "value must not be null"); + if (!VERSIONED_HMAC_SHA256.matcher(value).matches()) { + throw new IllegalArgumentException( + "subject digest must be a bounded versioned HMAC-SHA-256 representation"); + } + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java new file mode 100644 index 0000000..43dfe3d --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java @@ -0,0 +1,73 @@ +package dev.caskeleton.shared.ratelimit; + +import java.time.Duration; + +public sealed interface RateParameters + permits RateParameters.FixedWindow, RateParameters.SlidingCounter, RateParameters.TokenBucket { + + long MAXIMUM_EXACT_INTEGER = 9_007_199_254_740_991L; + long SCALE = 1_000_000L; + + record FixedWindow(long limit, Duration window) implements RateParameters { + + public FixedWindow { + requirePositiveExactInteger(limit, "limit"); + RateLimitBounds.positiveDuration(window, "window", RateLimitBounds.MAXIMUM_WINDOW); + } + } + + record SlidingCounter(long limit, Duration window) implements RateParameters { + + public SlidingCounter { + requirePositiveExactInteger(limit, "limit"); + RateLimitBounds.positiveDuration(window, "window", RateLimitBounds.MAXIMUM_WINDOW); + requireExactProduct("sliding-counter limit scale", limit, SCALE); + } + } + + record TokenBucket(long capacity, long refillTokens, Duration refillPeriod) + implements RateParameters { + + public TokenBucket { + requirePositiveExactInteger(capacity, "capacity"); + requirePositiveExactInteger(refillTokens, "refillTokens"); + RateLimitBounds.positiveDuration( + refillPeriod, "refillPeriod", RateLimitBounds.MAXIMUM_WINDOW); + requireExactProduct("token-bucket capacity scale", capacity, SCALE); + requireExactProduct("token-bucket refill scale", refillTokens, SCALE); + } + } + + private static void requirePositiveExactInteger(long value, String field) { + if (value <= 0 || value > MAXIMUM_EXACT_INTEGER) { + throw new IllegalArgumentException( + field + " must be positive and within Lua's exact integer range"); + } + } + + static long exactSum(String operation, long... terms) { + long result = 0; + for (long term : terms) { + if (term < 0 || result > MAXIMUM_EXACT_INTEGER - term) { + throw new IllegalArgumentException(operation + " exceeds Lua's exact integer range"); + } + result += term; + } + return result; + } + + static long exactProduct(String operation, long... factors) { + long result = 1; + for (long factor : factors) { + if (factor < 0 || (factor != 0 && result > MAXIMUM_EXACT_INTEGER / factor)) { + throw new IllegalArgumentException(operation + " exceeds Lua's exact integer range"); + } + result *= factor; + } + return result; + } + + private static void requireExactProduct(String operation, long... factors) { + exactProduct(operation, factors); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitContractTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitContractTest.java new file mode 100644 index 0000000..ede64f1 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitContractTest.java @@ -0,0 +1,189 @@ +package dev.caskeleton.shared.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RateLimitContractTest { + + private static final String SUBJECT_DIGEST = "a".repeat(64); + private static final String VERSIONED_SUBJECT_DIGEST = "v1:" + SUBJECT_DIGEST; + + @Test + void edgeSubjectIsBoundedBeforePseudonymizationAndRequestAcceptsOnlyTheDigestValue() { + EdgeRateLimitSubject subject = + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.PRINCIPAL, "user-42", "GET /v1/worklogs/{id}"); + RateLimitSubjectDigest digest = new RateLimitSubjectDigest(VERSIONED_SUBJECT_DIGEST); + Instant deadline = Instant.now().plusSeconds(30); + + RateLimitRequest request = new RateLimitRequest("login", digest, 1, "", deadline); + + assertThat(subject.kind()).isEqualTo(EdgeRateLimitSubject.Kind.PRINCIPAL); + assertThat(subject.canonicalIdentity()).isEqualTo("user-42"); + assertThat(subject.operationId()).isEqualTo("GET /v1/worklogs/{id}"); + assertThat(request.subjectDigest()).isEqualTo(VERSIONED_SUBJECT_DIGEST); + assertThat(request.subjectDigest()).doesNotContain(subject.canonicalIdentity()); + } + + @Test + void edgeSubjectAndDigestRejectUnboundedRawOrNonVersionedRepresentations() { + assertThatThrownBy( + () -> + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.CLIENT_IP, "x".repeat(513), "GET /v1/worklogs")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("canonicalIdentity"); + assertThatThrownBy( + () -> + new EdgeRateLimitSubject( + EdgeRateLimitSubject.Kind.PRINCIPAL, "user-42", "GET /" + "x".repeat(300))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationId"); + assertThatThrownBy(() -> new RateLimitSubjectDigest(SUBJECT_DIGEST)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("versioned"); + assertThatThrownBy(() -> new RateLimitSubjectDigest("v1:raw@example.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("versioned"); + } + + @Test + void requestRequiresBoundedPseudonymizedInputAndARepresentableDeadline() { + Instant deadline = Instant.now().plusSeconds(30); + String evaluationId = "ev1:" + "A".repeat(22); + + RateLimitRequest request = + new RateLimitRequest("login", SUBJECT_DIGEST, 2, evaluationId, deadline); + + assertThat(request.policyId()).isEqualTo("login"); + assertThat(request.subjectDigest()).isEqualTo(SUBJECT_DIGEST); + assertThat(request.cost()).isEqualTo(2); + assertThat(request.evaluationId()).isEqualTo(evaluationId); + assertThat(request.callerDeadline()).isEqualTo(deadline); + assertThatThrownBy( + () -> new RateLimitRequest("login", SUBJECT_DIGEST, 1, "client-value", deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("evaluationId"); + assertThatThrownBy( + () -> + new RateLimitRequest("login", SUBJECT_DIGEST, 1, "ev1:" + "A".repeat(65), deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("evaluationId"); + assertThat(new RateLimitRequest("login", SUBJECT_DIGEST, 1, "", deadline).evaluationId()) + .isEmpty(); + assertThat( + new RateLimitRequest("login", SUBJECT_DIGEST, 1, "", Instant.now().minusSeconds(1)) + .callerDeadline()) + .isBefore(Instant.now()); + assertThatThrownBy(() -> new RateLimitRequest("login", SUBJECT_DIGEST, 1, "", Instant.MAX)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("representable"); + assertThatThrownBy(() -> new RateLimitRequest("login", "raw@example.com", 1, "", deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("subjectDigest"); + } + + @Test + void requestRejectsUnboundedIdentifiersAndCost() { + Instant deadline = Instant.now().plusSeconds(30); + + assertThatThrownBy(() -> new RateLimitRequest("p".repeat(64), SUBJECT_DIGEST, 1, "", deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("policyId"); + assertThatThrownBy(() -> new RateLimitRequest("login", "a".repeat(129), 1, "", deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("subjectDigest"); + assertThatThrownBy(() -> new RateLimitRequest("login", SUBJECT_DIGEST, 0, "", deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cost"); + assertThatThrownBy( + () -> + new RateLimitRequest( + "login", + SUBJECT_DIGEST, + RateParameters.MAXIMUM_EXACT_INTEGER + 1, + "", + deadline)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cost"); + } + + @Test + void decisionAndOutcomesCarryProviderNeutralEnforcementMeaning() { + Instant resetAt = Instant.parse("2026-07-28T00:01:00Z"); + RateLimitDecision decision = + new RateLimitDecision( + false, + 10, + 0, + Duration.ofSeconds(2), + resetAt, + "login", + "v3", + RateLimitDecision.DecisionSource.GLOBAL_REDIS, + RateLimitDecision.DecisionCertainty.CERTAIN); + + assertThat(new RateLimitOutcome.Evaluated(decision).decision()).isEqualTo(decision); + assertThat( + new RateLimitOutcome.Unavailable( + "login", Duration.ofSeconds(1), RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE)) + .isInstanceOf(RateLimitOutcome.Unavailable.class); + assertThat(new RateLimitOutcome.Indeterminate("login", Duration.ofSeconds(1))) + .isInstanceOf(RateLimitOutcome.Indeterminate.class); + assertThat( + new RateLimitOutcome.Incompatible( + "login", RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE)) + .isInstanceOf(RateLimitOutcome.Incompatible.class); + } + + @Test + void decisionRejectsImpossibleRemainingAndRetrySemantics() { + Instant resetAt = Instant.parse("2026-07-28T00:01:00Z"); + + assertThatThrownBy( + () -> + new RateLimitDecision( + true, + 10, + 11, + Duration.ZERO, + resetAt, + "login", + "v3", + RateLimitDecision.DecisionSource.GLOBAL_REDIS, + RateLimitDecision.DecisionCertainty.CERTAIN)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("remaining"); + assertThatThrownBy( + () -> + new RateLimitDecision( + false, + 10, + 0, + Duration.ZERO, + resetAt, + "login", + "v3", + RateLimitDecision.DecisionSource.GLOBAL_REDIS, + RateLimitDecision.DecisionCertainty.CERTAIN)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("retryAfter"); + } + + @Test + void failureOutcomesRequireBoundedPositiveRetryHints() { + assertThatThrownBy( + () -> + new RateLimitOutcome.Unavailable( + "login", Duration.ZERO, RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("retryAfter"); + assertThatThrownBy(() -> new RateLimitOutcome.Indeterminate("login", Duration.ofNanos(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("milliseconds"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicyTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicyTest.java new file mode 100644 index 0000000..fa095a9 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicyTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.shared.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RateLimitEvaluationDedupPolicyTest { + + @Test + void enabledPolicyBoundsTtlEntriesAndStoredDataBytes() { + RateLimitEvaluationDedupPolicy policy = + new RateLimitEvaluationDedupPolicy(true, Duration.ofSeconds(5), 256, 65_536); + + assertThat(policy.enabled()).isTrue(); + assertThat(policy.timeToLive()).isEqualTo(Duration.ofSeconds(5)); + assertThat(policy.maximumEntries()).isEqualTo(256); + assertThat(policy.maximumStoredBytes()).isEqualTo(65_536); + } + + @Test + void disabledPolicyHasNoDedupStateAndEnabledPolicyRejectsUnsafeBounds() { + assertThat(RateLimitEvaluationDedupPolicy.disabled()) + .isEqualTo(new RateLimitEvaluationDedupPolicy(false, Duration.ZERO, 0, 0)); + assertThatThrownBy( + () -> new RateLimitEvaluationDedupPolicy(true, Duration.ofMinutes(6), 256, 65_536)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("timeToLive"); + assertThatThrownBy( + () -> new RateLimitEvaluationDedupPolicy(true, Duration.ofSeconds(5), 1025, 262_144)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumEntries"); + assertThatThrownBy( + () -> new RateLimitEvaluationDedupPolicy(true, Duration.ofSeconds(5), 256, 4096)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumStoredBytes"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitPolicyTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitPolicyTest.java new file mode 100644 index 0000000..ac12aca --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/ratelimit/RateLimitPolicyTest.java @@ -0,0 +1,159 @@ +package dev.caskeleton.shared.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RateLimitPolicyTest { + + @Test + void acceptsEachV1AlgorithmWithItsExactParameterSubtype() { + RateLimitPolicy fixed = + policy( + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(100, Duration.ofMinutes(1)), + 10); + RateLimitPolicy sliding = + policy( + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(100, Duration.ofMinutes(1)), + 10); + RateLimitPolicy token = + policy( + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(100, 10, Duration.ofSeconds(1)), + 10); + + assertThat(fixed.parameters()).isInstanceOf(RateParameters.FixedWindow.class); + assertThat(sliding.parameters()).isInstanceOf(RateParameters.SlidingCounter.class); + assertThat(token.parameters()).isInstanceOf(RateParameters.TokenBucket.class); + assertThat(fixed.failurePolicy()).isEqualTo(RateLimitFailurePolicy.FAIL_CLOSED); + } + + @Test + void rejectsAlgorithmParameterMismatchAndUnsupportedRequestCost() { + assertThatThrownBy( + () -> + policy( + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.TokenBucket(100, 10, Duration.ofSeconds(1)), + 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("algorithm"); + assertThatThrownBy( + () -> + policy( + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofSeconds(1)), + 11)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumCost"); + } + + @Test + void rejectsSlidingCounterIntermediatesBeyondLuaExactIntegerRange() { + long unsafeLimit = (RateParameters.MAXIMUM_EXACT_INTEGER / RateParameters.SCALE) / 2; + + assertThatThrownBy( + () -> + policy( + RateLimitAlgorithm.SLIDING_COUNTER, + new RateParameters.SlidingCounter(unsafeLimit, Duration.ofSeconds(1)), + unsafeLimit)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exact integer"); + } + + @Test + void rejectsTokenBucketFixedPointTimeProductsBeyondLuaExactIntegerRange() { + assertThatThrownBy( + () -> + policy( + RateLimitAlgorithm.TOKEN_BUCKET, + new RateParameters.TokenBucket(10_000_000, 1, Duration.ofDays(1)), + 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exact integer"); + } + + @Test + void rejectsFixedWindowAdmissionSumBeyondLuaExactIntegerRange() { + assertThatThrownBy( + () -> + policy( + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow( + RateParameters.MAXIMUM_EXACT_INTEGER, Duration.ofSeconds(1)), + 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exact integer"); + } + + @Test + void rejectsSubMillisecondAndOverlongDurations() { + assertThatThrownBy(() -> new RateParameters.FixedWindow(10, Duration.ofNanos(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("milliseconds"); + assertThatThrownBy(() -> new RateParameters.FixedWindow(10, Duration.ofDays(2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("PT24H"); + assertThatThrownBy( + () -> + new RateLimitPolicy( + "login", + "v3", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofSeconds(1)), + 1, + Duration.ofNanos(1), + Duration.ofSeconds(1), + RateLimitFailurePolicy.FAIL_CLOSED)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("milliseconds"); + } + + @Test + void rejectsZeroGraceAndClockRegressionBeyondTheLuaProgramBounds() { + assertThatThrownBy( + () -> + new RateLimitPolicy( + "login", + "v3", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofSeconds(1)), + 1, + Duration.ZERO, + Duration.ofSeconds(1), + RateLimitFailurePolicy.FAIL_CLOSED)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cleanupGrace"); + assertThatThrownBy( + () -> + new RateLimitPolicy( + "login", + "v3", + RateLimitAlgorithm.FIXED_WINDOW, + new RateParameters.FixedWindow(10, Duration.ofSeconds(1)), + 1, + Duration.ofSeconds(1), + Duration.ofHours(1).plusMillis(1), + RateLimitFailurePolicy.FAIL_CLOSED)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximumClockRegression"); + } + + private static RateLimitPolicy policy( + RateLimitAlgorithm algorithm, RateParameters parameters, long maximumCost) { + return new RateLimitPolicy( + "login", + "v3", + algorithm, + parameters, + maximumCost, + Duration.ofSeconds(5), + Duration.ofSeconds(1), + RateLimitFailurePolicy.FAIL_CLOSED); + } +}